From e6ac117f9bb997be42d90e9db85756d201e8dc32 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 07:21:19 +1300 Subject: [PATCH 01/45] Keep wires attached when a project's copy of a symbol is replaced A wire whose terminals have uuids is saved against them, and on load it is reattached to a terminal with that uuid or dropped, with only a qDebug line. Re-importing a changed symbol and choosing "replace" swapped the project's definition for one whose terminal uuids differ; the placed symbols kept the old ones until the project was reopened, so every wire on them was lost at the next open, silently, and gone for good at the next save. - XmlElementCollection::copyElement(), where an embedded definition is overwritten, carries each old terminal uuid onto the new terminal at the same place and orientation (TerminalUuids::keep()). Terminals that moved, and new ones, keep their own. - Diagram::fromXml() records wires it could not reattach, logs them, and the editor lists them in one warning after opening a project. Measured on 2612_ats_singlephase.qet with the stored splice's terminal uuids made to differ from the collection's: replace, save, reopen loads 34 of 131 wires on master, 131 with this change (GUI, both arms). tst_terminaluuids covers keep() and runs the real loader. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + sources/ElementsCollection/terminaluuids.cpp | 89 +++++++++ sources/ElementsCollection/terminaluuids.h | 28 +++ .../xmlelementcollection.cpp | 2 + sources/diagram.cpp | 47 +++++ sources/diagram.h | 6 +- sources/qetdiagrameditor.cpp | 28 +++ tests/qttest/CMakeLists.txt | 14 ++ tests/qttest/tst_terminaluuids.cpp | 186 ++++++++++++++++++ 9 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 sources/ElementsCollection/terminaluuids.cpp create mode 100644 sources/ElementsCollection/terminaluuids.h create mode 100644 tests/qttest/tst_terminaluuids.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index f46101bfa..5b3f13eb7 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -475,6 +475,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ElementsCollection/elementstreeview.h ${QET_DIR}/sources/ElementsCollection/fileelementcollectionitem.cpp ${QET_DIR}/sources/ElementsCollection/fileelementcollectionitem.h + ${QET_DIR}/sources/ElementsCollection/terminaluuids.cpp + ${QET_DIR}/sources/ElementsCollection/terminaluuids.h ${QET_DIR}/sources/ElementsCollection/xmlelementcollection.cpp ${QET_DIR}/sources/ElementsCollection/xmlelementcollection.h ${QET_DIR}/sources/ElementsCollection/xmlprojectelementcollectionitem.cpp diff --git a/sources/ElementsCollection/terminaluuids.cpp b/sources/ElementsCollection/terminaluuids.cpp new file mode 100644 index 000000000..d19f831f8 --- /dev/null +++ b/sources/ElementsCollection/terminaluuids.cpp @@ -0,0 +1,89 @@ +/* + 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 . +*/ +#include "terminaluuids.h" + +#include +#include +#include + +namespace { +/** + @return the of the definition held by the embedded + collection element @p collection_element +*/ +QList terminalsOf(const QDomElement &collection_element) +{ + QList terminals; + const QDomElement description = collection_element + .firstChildElement(QStringLiteral("definition")) + .firstChildElement(QStringLiteral("description")); + for (QDomElement t = description.firstChildElement(QStringLiteral("terminal")); + !t.isNull(); + t = t.nextSiblingElement(QStringLiteral("terminal"))) { + terminals << t; + } + return terminals; +} + + //Place and orientation, "10" and "10.0" being the same place +QString terminalPlace(const QDomElement &terminal) +{ + return QStringLiteral("%1|%2|%3") + .arg(QString::number(terminal.attribute(QStringLiteral("x")).toDouble()), + QString::number(terminal.attribute(QStringLiteral("y")).toDouble()), + terminal.attribute(QStringLiteral("orientation"))); +} +} + +/** + @brief TerminalUuids::keep + Give each terminal of @p new_element the uuid of the terminal of + @p old_element at the same place and orientation. Both are + of an embedded collection. + + A wire is saved against the uuids of the terminals it joins, and on + loading it is reattached to a terminal with that uuid or not at all. + The symbols already on the folios keep the terminals of the definition + they were built from, so when the project's definition is replaced by + one whose terminal uuids differ -- another copy of the same symbol, or + one saved by a different version of the collection -- every wire on + those symbols was lost the next time the project was opened. + + Terminals that moved, and new ones, keep their own uuid. Where the old + definition has two terminals at one place, they are matched in order. +*/ +void TerminalUuids::keep(const QDomElement &old_element, QDomElement &new_element) +{ + QHash old_uuids; + for (const QDomElement &t : terminalsOf(old_element)) { + const QString uuid = t.attribute(QStringLiteral("uuid")); + if (!uuid.isEmpty()) { + old_uuids[terminalPlace(t)] << uuid; + } + } + if (old_uuids.isEmpty()) { + return; + } + + for (QDomElement t : terminalsOf(new_element)) { + QStringList &uuids = old_uuids[terminalPlace(t)]; + if (!uuids.isEmpty()) { + t.setAttribute(QStringLiteral("uuid"), uuids.takeFirst()); + } + } +} diff --git a/sources/ElementsCollection/terminaluuids.h b/sources/ElementsCollection/terminaluuids.h new file mode 100644 index 000000000..872acbdd7 --- /dev/null +++ b/sources/ElementsCollection/terminaluuids.h @@ -0,0 +1,28 @@ +/* + 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 . +*/ +#ifndef TERMINALUUIDS_H +#define TERMINALUUIDS_H + +class QDomElement; + +namespace TerminalUuids +{ + void keep(const QDomElement &old_element, QDomElement &new_element); +} + +#endif // TERMINALUUIDS_H diff --git a/sources/ElementsCollection/xmlelementcollection.cpp b/sources/ElementsCollection/xmlelementcollection.cpp index b124b72ff..ee6a8b1be 100644 --- a/sources/ElementsCollection/xmlelementcollection.cpp +++ b/sources/ElementsCollection/xmlelementcollection.cpp @@ -21,6 +21,7 @@ #include "../qetproject.h" #include "../qetxml.h" #include "elementslocation.h" +#include "terminaluuids.h" /** @brief XmlElementCollection::XmlElementCollection @@ -914,6 +915,7 @@ ElementsLocation XmlElementCollection::copyElement( % "/" % new_elmt_name); bool removed = false; if (!element.isNull()) { + TerminalUuids::keep(element, elmt_dom); element.parentNode().removeChild(element); removed = true; } diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 008fa9495..9ffaede29 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -773,6 +773,18 @@ QUuid Diagram::uuid() return m_uuid; } +/** + @brief Diagram::wiresNotReconnected + @return one line per wire of the loaded file that was left out because + a terminal it joins could not be found, for example after the symbol's + definition in the project was replaced by one whose terminals differ. + Empty for a folio that loaded every wire. +*/ +QStringList Diagram::wiresNotReconnected() const +{ + return m_wires_not_reconnected; +} + /** @brief Diagram::uuidUsedByOtherDiagram A hand-edited or merged project file can contain two folios with the same @@ -1837,6 +1849,8 @@ bool Diagram::fromXml(QDomElement &document, } // Load conductor + if (consider_informations) + m_wires_not_reconnected.clear(); QList added_conductors; for (auto f : QET::findInDomElement(root, QStringLiteral("conductors"), @@ -1849,6 +1863,33 @@ bool Diagram::fromXml(QDomElement &document, Terminal* p1 = findTerminal(1, f, table_adr_id, added_elements); Terminal* p2 = findTerminal(2, f, table_adr_id, added_elements); + //Keep a trace of the wire, it will be missing from the next save + if ((!p1 || !p2) && consider_informations) + { + //The symbol's label, else its name. For an end not found, + //only the uuid form of a wire says which symbol it is on. + auto end_label = [&f, &added_elements](const QString &index, + Terminal *found) { + Element *element = found ? found->parentElement() : nullptr; + const QUuid uuid(f.attribute(QStringLiteral("element") + index)); + for (int i = 0 ; !element && !uuid.isNull() + && i < added_elements.size() ; ++i) { + if (added_elements.at(i)->uuid() == uuid) + element = added_elements.at(i); + } + if (!element) + return QStringLiteral("?"); + const QString label = element->actualLabel(); + return label.isEmpty() ? element->name() : label; + }; + QString wire = QStringLiteral("%1 - %2").arg(end_label(QStringLiteral("1"), p1), + end_label(QStringLiteral("2"), p2)); + const QString num = f.attribute(QStringLiteral("num")); + if (!num.isEmpty() && num != QLatin1String("_")) + wire += QStringLiteral(" (%1)").arg(num); + m_wires_not_reconnected << wire; + } + if (p1 && p2 && p1 != p2) { Conductor *c = new Conductor(p1, p2); @@ -1884,6 +1925,12 @@ bool Diagram::fromXml(QDomElement &document, delete c; } } + if (!m_wires_not_reconnected.isEmpty()) { + qWarning().noquote() << "Diagram::fromXml():" + << m_wires_not_reconnected.size() + << "wire(s) not loaded, a terminal they join was not found:" + << m_wires_not_reconnected.join(QStringLiteral(", ")); + } //Filling of falculatory lists if (content_ptr) { diff --git a/sources/diagram.h b/sources/diagram.h index 83d408835..96299d036 100644 --- a/sources/diagram.h +++ b/sources/diagram.h @@ -148,7 +148,10 @@ class Diagram : public QGraphicsScene bool uuidUsedByOtherDiagram(const QUuid &uuid) const; QUuid derivedUuid(const QDomElement &root, const QString &reason) const; - + + //Wires of the loaded file whose ends could not be found + QStringList m_wires_not_reconnected; + // METHODS protected: void drawBackground(QPainter *, const QRectF &) override; @@ -171,6 +174,7 @@ class Diagram : public QGraphicsScene void correctTextPos(Element* elmt); void restoreText(Element* elmt); QUuid uuid(); + QStringList wiresNotReconnected() const; void setEventInterface (DiagramEventInterface *event_interface); void clearEventInterface(); diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 80d52f4dd..7cb805bc3 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -1672,6 +1672,34 @@ bool QETDiagramEditor::openAndAddProject( ); } + //Report wires left out of the load because a terminal they join + //was not found: they would otherwise vanish on the next save + //without the user ever being told. + QStringList lost_wires; + for (Diagram *diagram : project->diagrams()) { + for (const QString &wire : diagram->wiresNotReconnected()) { + lost_wires << tr("Folio %1 : %2").arg(diagram->folioIndex() + 1).arg(wire); + } + } + if (interactive && !lost_wires.isEmpty()) + { + QMessageBox box(QMessageBox::Warning, + tr("Conducteurs non chargés", "message box title"), + tr("%n conducteur(s) n'ont pas pu être reliés à leurs" + " bornes et n'ont pas été chargés. La définition de" + " l'élément dans le projet a probablement été remplacée" + " par une autre dont les bornes diffèrent.\n\n" + "Si vous enregistrez le projet, ces conducteurs" + " disparaîtront du fichier. Fermez-le sans enregistrer pour" + " conserver le fichier tel quel.", + "message box content", + lost_wires.size()), + QMessageBox::Ok, + this); + box.setDetailedText(lost_wires.join(QLatin1Char('\n'))); + box.exec(); + } + BackupDialog backup_dialog(this); if (backup_dialog.exec() == QDialog::Accepted) { diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e2fde811c..9656c557f 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -385,6 +385,20 @@ target_compile_definitions(tst_resaveunchanged PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") +# Replacing a symbol's definition in a project keeps its terminal uuids, so +# the wires saved against them still load: TerminalUuids::keep() on its own, +# then the real binary's --info on an example whose symbols were replaced. +add_executable( + tst_terminaluuids + tst_terminaluuids.cpp + ${QET_DIR}/sources/ElementsCollection/terminaluuids.cpp) +add_test(NAME tst_terminaluuids COMMAND tst_terminaluuids) +add_dependencies(tst_terminaluuids qelectrotech) +target_link_libraries(tst_terminaluuids PRIVATE Qt::Test Qt::Xml) +target_compile_definitions(tst_terminaluuids PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # DiagramContext::fromXml() -- the two readers (QDom for projects, pugixml # for element definitions in the collection) give the same values: stray # spaces trimmed, accents kept. diff --git a/tests/qttest/tst_terminaluuids.cpp b/tests/qttest/tst_terminaluuids.cpp new file mode 100644 index 000000000..e641f3c57 --- /dev/null +++ b/tests/qttest/tst_terminaluuids.cpp @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "../../sources/ElementsCollection/terminaluuids.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Replacing a symbol's definition in a project must not cost the wires on +// it. A wire is saved against the uuids of the terminals it joins and is +// reattached on load to a terminal with that uuid, or not at all; the +// replacement is another copy of the symbol, whose terminals usually carry +// other uuids. TerminalUuids::keep() carries the old uuids over. +namespace { + +QDomElement symbol(QDomDocument &doc, const QList &terminals) +{ + QDomElement element = doc.createElement(QStringLiteral("element")); + QDomElement definition = doc.createElement(QStringLiteral("definition")); + QDomElement description = doc.createElement(QStringLiteral("description")); + element.appendChild(definition); + definition.appendChild(description); + for (const QStringList &t : terminals) { + QDomElement terminal = doc.createElement(QStringLiteral("terminal")); + terminal.setAttribute(QStringLiteral("x"), t.at(0)); + terminal.setAttribute(QStringLiteral("y"), t.at(1)); + terminal.setAttribute(QStringLiteral("orientation"), t.at(2)); + if (t.size() > 3) + terminal.setAttribute(QStringLiteral("uuid"), t.at(3)); + description.appendChild(terminal); + } + return element; +} + +QStringList uuids(const QDomElement &element) +{ + QStringList out; + const QDomNodeList nodes = element.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < nodes.size(); ++i) + out << nodes.at(i).toElement().attribute(QStringLiteral("uuid")); + return out; +} + +QList embeddedSymbols(const QDomDocument &doc) +{ + QList out; + const QDomNodeList nodes = doc.documentElement() + .firstChildElement(QStringLiteral("collection")) + .elementsByTagName(QStringLiteral("element")); + for (int i = 0; i < nodes.size(); ++i) + out << nodes.at(i).toElement(); + return out; +} + +} + +class tst_terminaluuids : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + // --info on @p project in a sandbox of its own; returns the number of + // wires loaded and puts what was written on stderr in @p log. + int loadedWires(const QString &project, QString *log) + { + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); + QDir().mkpath(home); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--info"), project}); + if (!proc.waitForFinished(120000) || proc.exitCode() != 0) + return -1; + *log = QString::fromUtf8(proc.readAllStandardError()); + const QByteArray out = proc.readAllStandardOutput(); + const QJsonDocument json = QJsonDocument::fromJson(out.mid(out.indexOf('{'))); + return json.object().value(QStringLiteral("conductors")).toInt(-1); + } + + // 2612_ats_singlephase.qet with each embedded symbol replaced by a copy + // whose terminals carry new uuids, passed through keep() or not. + QString replacedSymbols(bool keep_uuids) + { + QFile in(QStringLiteral(QET_EXAMPLES_DIR "/2612_ats_singlephase.qet")); + if (!in.open(QIODevice::ReadOnly)) return {}; + QDomDocument doc; + if (!doc.setContent(&in)) return {}; + for (const QDomElement &old_symbol : embeddedSymbols(doc)) { + QDomElement new_symbol = old_symbol.cloneNode().toElement(); + const QDomNodeList terminals = new_symbol.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < terminals.size(); ++i) + terminals.at(i).toElement().setAttribute(QStringLiteral("uuid"), + QUuid::createUuid().toString()); + if (keep_uuids) + TerminalUuids::keep(old_symbol, new_symbol); + old_symbol.parentNode().replaceChild(new_symbol, old_symbol); + } + const QString path = m_dir.filePath(keep_uuids ? QStringLiteral("kept.qet") + : QStringLiteral("lost.qet")); + QFile out(path); + if (!out.open(QIODevice::WriteOnly)) return {}; + out.write(doc.toByteArray()); + return path; + } + +private slots: + void samePlaceTakesOldUuid() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"}, + {"20", "10", "s", "{b}"}}); + QDomElement new_symbol = symbol(doc, {{"20.0", "10", "s", "{y}"}, + {"0", "10.0", "n", "{x}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{b}", "{a}"})); + } + + void movedOrNewTerminalKeepsItsOwn() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "10", "e", "{x}"}, + {"0", "20", "n", "{y}"}, + {"0", "30", "n"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{x}", "{y}", ""})); + } + + void oldWithoutUuidChangesNothing() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n"}}); + QDomElement new_symbol = symbol(doc, {{"0", "10", "n", "{x}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{x}"})); + } + + void twoAtOnePlaceMatchedInOrder() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "10", "n", "{a}"}, + {"0", "10", "n", "{b}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "10", "n", "{x}"}, + {"0", "10", "n", "{y}"}, + {"0", "10", "n", "{z}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{b}", "{z}"})); + } + + // The real loader, on a project whose 131 wires are saved against + // terminal uuids: replaced symbols lose 119 of them unless keep() ran, + // and the ones lost are reported. + void wiresSurviveReplacedSymbols() + { + QVERIFY(m_dir.isValid()); + QString log; + + const QString lost = replacedSymbols(false); + QVERIFY(!lost.isEmpty()); + QCOMPARE(loadedWires(lost, &log), 12); + QVERIFY2(log.contains(QStringLiteral("55 wire(s) not loaded")) + && log.contains(QStringLiteral("64 wire(s) not loaded")), + "the lost wires were not reported"); + + const QString kept = replacedSymbols(true); + QVERIFY(!kept.isEmpty()); + QCOMPARE(loadedWires(kept, &log), 131); + QVERIFY2(!log.contains(QStringLiteral("not loaded")), "a wire was reported lost"); + } +}; + +QTEST_APPLESS_MAIN(tst_terminaluuids) + +#include "tst_terminaluuids.moc" From 079d085a874d88e97849d1cb627d580c4e49b969 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 07:32:12 +1300 Subject: [PATCH 02/45] Keep terminal uuids when a whole category is replaced; never duplicate one Review of the previous commit: - keep() could give an old uuid to a new terminal while another terminal of the new definition already carried it (a moved terminal), leaving two terminals with one uuid. A terminal carrying any old uuid is now left alone, and an old uuid already in use is never handed out. - copyDirectory() replaced a whole category of the embedded collection (drag a folder onto the project's folder of the same name) without carrying terminal uuids over. keepInDirectory() walks both trees by name and calls keep() on each symbol. - The "wire(s) not loaded" log line repeated the folio's list on every paste; it is now written only when a folio is loaded. tst_terminaluuids: 3 new cases, each red on the previous keep(). Co-Authored-By: Claude Opus 5.5 --- sources/ElementsCollection/terminaluuids.cpp | 65 +++++++++++++++++-- sources/ElementsCollection/terminaluuids.h | 2 + .../xmlelementcollection.cpp | 9 +++ sources/diagram.cpp | 2 +- tests/qttest/tst_terminaluuids.cpp | 62 ++++++++++++++++++ 5 files changed, 135 insertions(+), 5 deletions(-) diff --git a/sources/ElementsCollection/terminaluuids.cpp b/sources/ElementsCollection/terminaluuids.cpp index d19f831f8..ce45ec477 100644 --- a/sources/ElementsCollection/terminaluuids.cpp +++ b/sources/ElementsCollection/terminaluuids.cpp @@ -19,6 +19,7 @@ #include #include +#include #include namespace { @@ -64,26 +65,82 @@ QString terminalPlace(const QDomElement &terminal) one saved by a different version of the collection -- every wire on those symbols was lost the next time the project was opened. - Terminals that moved, and new ones, keep their own uuid. Where the old - definition has two terminals at one place, they are matched in order. + A terminal of the new definition that already carries one of the old + uuids is that same terminal, perhaps moved: it keeps it, and that uuid + is not given to anything else. Other terminals that moved, and new + ones, keep their own. No uuid is ever given to two terminals. Where the + old definition has two terminals at one place, they are matched in + order. */ void TerminalUuids::keep(const QDomElement &old_element, QDomElement &new_element) { QHash old_uuids; + QSet all_old_uuids; for (const QDomElement &t : terminalsOf(old_element)) { const QString uuid = t.attribute(QStringLiteral("uuid")); if (!uuid.isEmpty()) { old_uuids[terminalPlace(t)] << uuid; + all_old_uuids << uuid; } } if (old_uuids.isEmpty()) { return; } - for (QDomElement t : terminalsOf(new_element)) { + const QList new_terminals = terminalsOf(new_element); + QSet taken; + for (const QDomElement &t : new_terminals) { + taken << t.attribute(QStringLiteral("uuid")); + } + + for (QDomElement t : new_terminals) { + const QString own = t.attribute(QStringLiteral("uuid")); + if (all_old_uuids.contains(own)) { + continue; + } QStringList &uuids = old_uuids[terminalPlace(t)]; + while (!uuids.isEmpty() && taken.contains(uuids.first())) { + uuids.removeFirst(); + } if (!uuids.isEmpty()) { - t.setAttribute(QStringLiteral("uuid"), uuids.takeFirst()); + const QString uuid = uuids.takeFirst(); + taken.remove(own); + taken << uuid; + t.setAttribute(QStringLiteral("uuid"), uuid); + } + } +} + +/** + @brief TerminalUuids::keepInDirectory + keep() for every symbol of @p new_directory that has a counterpart of + the same name at the same place under @p old_directory. Both are + of an embedded collection; used when a whole category of + the project is replaced. +*/ +void TerminalUuids::keepInDirectory(const QDomElement &old_directory, + QDomElement &new_directory) +{ + for (QDomElement child = new_directory.firstChildElement(); + !child.isNull(); + child = child.nextSiblingElement()) { + if (child.tagName() != QLatin1String("category") + && child.tagName() != QLatin1String("element")) { + continue; + } + const QString name = child.attribute(QStringLiteral("name")); + QDomElement old_child = old_directory.firstChildElement(child.tagName()); + while (!old_child.isNull() + && old_child.attribute(QStringLiteral("name")) != name) { + old_child = old_child.nextSiblingElement(child.tagName()); + } + if (old_child.isNull()) { + continue; + } + if (child.tagName() == QLatin1String("category")) { + keepInDirectory(old_child, child); + } else { + keep(old_child, child); } } } diff --git a/sources/ElementsCollection/terminaluuids.h b/sources/ElementsCollection/terminaluuids.h index 872acbdd7..f22c7dbec 100644 --- a/sources/ElementsCollection/terminaluuids.h +++ b/sources/ElementsCollection/terminaluuids.h @@ -23,6 +23,8 @@ class QDomElement; namespace TerminalUuids { void keep(const QDomElement &old_element, QDomElement &new_element); + void keepInDirectory(const QDomElement &old_directory, + QDomElement &new_directory); } #endif // TERMINALUUIDS_H diff --git a/sources/ElementsCollection/xmlelementcollection.cpp b/sources/ElementsCollection/xmlelementcollection.cpp index ee6a8b1be..ebd64f886 100644 --- a/sources/ElementsCollection/xmlelementcollection.cpp +++ b/sources/ElementsCollection/xmlelementcollection.cpp @@ -870,6 +870,15 @@ ElementsLocation XmlElementCollection::copyDirectory( created_location.setPath(destination.projectCollectionPath() % "/" % new_dir_name); } + //The symbols of the replaced directory keep their terminal uuids, + //see TerminalUuids::keep() + if (!element.isNull()) { + QDomElement new_dir_dom = directory(created_location.collectionPath(false)); + if (!new_dir_dom.isNull()) { + TerminalUuids::keepInDirectory(element, new_dir_dom); + } + } + emit directorieAdded(created_location.collectionPath(false)); return created_location; } diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 9ffaede29..332f083b6 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -1925,7 +1925,7 @@ bool Diagram::fromXml(QDomElement &document, delete c; } } - if (!m_wires_not_reconnected.isEmpty()) { + if (consider_informations && !m_wires_not_reconnected.isEmpty()) { qWarning().noquote() << "Diagram::fromXml():" << m_wires_not_reconnected.size() << "wire(s) not loaded, a terminal they join was not found:" diff --git a/tests/qttest/tst_terminaluuids.cpp b/tests/qttest/tst_terminaluuids.cpp index e641f3c57..9da458228 100644 --- a/tests/qttest/tst_terminaluuids.cpp +++ b/tests/qttest/tst_terminaluuids.cpp @@ -159,6 +159,68 @@ private slots: QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{b}", "{z}"})); } + // The new revision moved terminal {a} and put a new one where it was: + // {a} is still {a}, and the new one is not given {a} a second time. + void noUuidGivenTwice() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "0", "n", "{a}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "5", "n", "{a}"}, + {"0", "0", "n", "{c}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{c}"})); + } + + // A terminal that already carries an old uuid is never renamed, even + // when it moved onto the place of another old terminal. + void movedTerminalNotRenamed() + { + QDomDocument doc; + const QDomElement old_symbol = symbol(doc, {{"0", "0", "n", "{a}"}, + {"0", "5", "n", "{b}"}}); + QDomElement new_symbol = symbol(doc, {{"0", "5", "n", "{a}"}, + {"0", "9", "n", "{x}"}}); + TerminalUuids::keep(old_symbol, new_symbol); + QCOMPARE(uuids(new_symbol), (QStringList{"{a}", "{x}"})); + } + + // Replacing a whole category: every symbol with a counterpart of the + // same name, at any depth, keeps its terminal uuids. + void keepInDirectory() + { + QDomDocument doc; + auto category = [&doc](const QString &name) { + QDomElement c = doc.createElement(QStringLiteral("category")); + c.setAttribute(QStringLiteral("name"), name); + return c; + }; + auto named = [](QDomElement e, const QString &name) { + e.setAttribute(QStringLiteral("name"), name); + return e; + }; + + QDomElement old_dir = category(QStringLiteral("dir")); + QDomElement old_sub = category(QStringLiteral("sub")); + old_dir.appendChild(named(symbol(doc, {{"0", "0", "n", "{a}"}}), "top.elmt")); + old_dir.appendChild(old_sub); + old_sub.appendChild(named(symbol(doc, {{"0", "0", "n", "{b}"}}), "deep.elmt")); + + QDomElement new_dir = category(QStringLiteral("dir")); + QDomElement new_sub = category(QStringLiteral("sub")); + const QDomElement top = named(symbol(doc, {{"0", "0", "n", "{x}"}}), "top.elmt"); + const QDomElement other = named(symbol(doc, {{"0", "0", "n", "{y}"}}), "other.elmt"); + const QDomElement deep = named(symbol(doc, {{"0", "0", "n", "{z}"}}), "deep.elmt"); + new_dir.appendChild(top); + new_dir.appendChild(other); + new_dir.appendChild(new_sub); + new_sub.appendChild(deep); + + TerminalUuids::keepInDirectory(old_dir, new_dir); + QCOMPARE(uuids(top), QStringList{"{a}"}); + QCOMPARE(uuids(other), QStringList{"{y}"}); + QCOMPARE(uuids(deep), QStringList{"{b}"}); + } + // The real loader, on a project whose 131 wires are saved against // terminal uuids: replaced symbols lose 119 of them unless keep() ran, // and the ones lost are reported. From 4e6f59e011d7842093b122af4ee15167ab2577a6 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 08:50:27 +1300 Subject: [PATCH 03/45] Give terminals in older projects a lasting uuid Most symbols stored in older projects have no uuid on their terminals (706 of the 900 in the 24 examples), so a terminal's identity is worked out from where it sits in its symbol on every load (stableUuid()). That is only sound while nothing keyed on it is kept between loads. - On opening a project, every terminal of its embedded symbols without a uuid gets that same derived value (TerminalUuids::fillMissing(), from XmlElementCollection's loading constructor, before any folio is built). The next save writes it, and the wires on it in the form that names terminals by uuid, which QElectroTech reads since 0.8.0. - The recipe moves to TerminalUuids::derived(), which stableUuid() now calls, so the two cannot drift apart. A second terminal at the same point of a symbol gets the next occurrence, and no value is given twice within a symbol. - findTerminal(): a wire whose terminal uuid is not found is matched to the terminal whose derived value it is, so a saved uuid still finds its terminal after the symbol's definition was replaced by one whose terminals carry other uuids. The project database's terminal and conductor tables are identical before and after on all 24 examples except the 3 terminals that share a point with another in their symbol (industrial.qet 1, perceuse.qet 2), which now have an identity of their own. Every example keeps every wire through a resave, and a second save changes nothing. Co-Authored-By: Claude Opus 5.5 --- sources/ElementsCollection/terminaluuids.cpp | 103 ++++++++++++ sources/ElementsCollection/terminaluuids.h | 4 + .../xmlelementcollection.cpp | 6 +- sources/diagram.cpp | 8 + sources/qetgraphicsitem/terminal.cpp | 47 +++--- sources/qetgraphicsitem/terminal.h | 1 + tests/qttest/tst_terminaluuids.cpp | 157 +++++++++++++++++- 7 files changed, 299 insertions(+), 27 deletions(-) diff --git a/sources/ElementsCollection/terminaluuids.cpp b/sources/ElementsCollection/terminaluuids.cpp index ce45ec477..451a8f7a3 100644 --- a/sources/ElementsCollection/terminaluuids.cpp +++ b/sources/ElementsCollection/terminaluuids.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include @@ -41,6 +42,62 @@ QList terminalsOf(const QDomElement &collection_element) return terminals; } + //Qet::orientationFromString(), without pulling in qet.cpp +int orientationOf(const QDomElement &terminal) +{ + const QString o = terminal.attribute(QStringLiteral("orientation")); + if (o.startsWith(QLatin1Char('e'))) return 1; + if (o.startsWith(QLatin1Char('s'))) return 2; + if (o.startsWith(QLatin1Char('w'))) return 3; + return 0; +} + + //The of one element definition get a uuid where missing +int fillDefinition(const QDomElement &collection_element) +{ + const QList terminals = terminalsOf(collection_element); + QSet taken; + for (const QDomElement &t : terminals) { + const QUuid uuid(t.attribute(QStringLiteral("uuid"))); + if (!uuid.isNull()) { + taken << uuid; + } + } + + int filled = 0; + for (QDomElement t : terminals) { + if (!QUuid(t.attribute(QStringLiteral("uuid"))).isNull()) { + continue; + } + const qreal x = t.attribute(QStringLiteral("x")).toDouble(); + const qreal y = t.attribute(QStringLiteral("y")).toDouble(); + const int orientation = orientationOf(t); + QUuid uuid; + for (int occurrence = 0 ; uuid.isNull() || taken.contains(uuid) ; ++occurrence) { + uuid = TerminalUuids::derived(x, y, orientation, occurrence); + } + taken << uuid; + t.setAttribute(QStringLiteral("uuid"), uuid.toString()); + ++filled; + } + return filled; +} + +int fillDirectory(const QDomElement &directory) +{ + int filled = 0; + for (QDomElement child = directory.firstChildElement(); + !child.isNull(); + child = child.nextSiblingElement()) { + if (child.tagName() == QLatin1String("category")) { + filled += fillDirectory(child); + } else if (child.tagName() == QLatin1String("element")) { + filled += fillDefinition(child); + } + } + return filled; +} + //Place and orientation, "10" and "10.0" being the same place QString terminalPlace(const QDomElement &terminal) { @@ -144,3 +201,49 @@ void TerminalUuids::keepInDirectory(const QDomElement &old_directory, } } } + +/** + @brief TerminalUuids::derived + The identity of a terminal that carries no uuid, worked out from where + it is inside its symbol: its local position and orientation, which are + what the project file itself uses to tell terminals apart. UUID v5 in a + fixed namespace, so the same terminal gets the same value in any + project, on any machine, and it cannot collide with the random (v4) + uuids the element editor gives. + + @param orientation Qet::Orientation, as an int + @param occurrence 0 for the value Terminal::stableUuid() uses; 1, 2... + for a second, third... terminal at the same point of the same symbol +*/ +QUuid TerminalUuids::derived(qreal x, qreal y, int orientation, int occurrence) +{ + //Fixed namespace for terminal identities derived from geometry. + static const QUuid derived_ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}")); + + QString key = QStringLiteral("%1|%2|%3") + .arg(x, 0, 'f', 4) + .arg(y, 0, 'f', 4) + .arg(orientation); + if (occurrence > 0) { + key += QStringLiteral("|%1").arg(occurrence); + } + return QUuid::createUuidV5(derived_ns, key); +} + +/** + @brief TerminalUuids::fillMissing + Give every terminal without a uuid, in every symbol of the embedded + collection @p collection_root, the value derived() works out for it -- + the one QElectroTech already used for it as Terminal::stableUuid(), so + nothing keyed on terminals changes. Saved with the project, it becomes + a lasting identity: moving the terminal in the symbol editor no longer + changes it. + + Where one symbol has two terminals at one point, the second gets the + next occurrence; a value is never given twice within a symbol. + @return the number of terminals given a uuid +*/ +int TerminalUuids::fillMissing(const QDomElement &collection_root) +{ + return fillDirectory(collection_root); +} diff --git a/sources/ElementsCollection/terminaluuids.h b/sources/ElementsCollection/terminaluuids.h index f22c7dbec..d5c94f517 100644 --- a/sources/ElementsCollection/terminaluuids.h +++ b/sources/ElementsCollection/terminaluuids.h @@ -18,10 +18,14 @@ #ifndef TERMINALUUIDS_H #define TERMINALUUIDS_H +#include + class QDomElement; namespace TerminalUuids { + QUuid derived(qreal x, qreal y, int orientation, int occurrence = 0); + int fillMissing(const QDomElement &collection_root); void keep(const QDomElement &old_element, QDomElement &new_element); void keepInDirectory(const QDomElement &old_directory, QDomElement &new_directory); diff --git a/sources/ElementsCollection/xmlelementcollection.cpp b/sources/ElementsCollection/xmlelementcollection.cpp index ebd64f886..940988da6 100644 --- a/sources/ElementsCollection/xmlelementcollection.cpp +++ b/sources/ElementsCollection/xmlelementcollection.cpp @@ -128,9 +128,13 @@ XmlElementCollection::XmlElementCollection(const QDomElement &dom_element, QObject(project), m_project(project) { - if (dom_element.tagName() == "collection") + if (dom_element.tagName() == "collection") { m_dom_document.appendChild(m_dom_document.importNode( dom_element, true)); + //Before any folio is built from these symbols, so that their + //terminals carry the uuid the next save writes + TerminalUuids::fillMissing(root()); + } else qDebug() << "XmlElementCollection : tagName of dom_element is not collection"; } diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 332f083b6..3bb984338 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -1491,6 +1491,14 @@ Terminal* findTerminal(int conductor_index, continue; return terminal; + } + //The uuid a project gave a terminal on opening is worked out + //from where the terminal is in its symbol: if the symbol's + //definition has since been replaced by one whose terminals + //carry other uuids, the terminal at that place is still it. + for (auto terminal: element->terminals()) { + if (terminal->derivedUuid() == terminal_uuid) + return terminal; } qDebug() << "Diagram::fromXml() : " << terminal_index diff --git a/sources/qetgraphicsitem/terminal.cpp b/sources/qetgraphicsitem/terminal.cpp index a1c9af780..5c1a511c4 100644 --- a/sources/qetgraphicsitem/terminal.cpp +++ b/sources/qetgraphicsitem/terminal.cpp @@ -16,6 +16,7 @@ along with QElectroTech. If not, see . */ #include "../qetgraphicsitem/terminal.h" +#include "../ElementsCollection/terminaluuids.h" #include "../qet.h" #include "../qetproject.h" #include "../conductorautonumerotation.h" @@ -849,11 +850,11 @@ QUuid Terminal::uuid() const read from the definition and is not touched by moving the element on the folio, so the result is stable across loads, saves and folio moves, and it is unique within an element except where a definition genuinely declares - two terminals at the same point -- three cases in the whole example corpus, - and harmless, because two terminals sharing a position and orientation are - indistinguishable in every observable respect: they merge to one terminal - row and every conductor on either of them still resolves to the right - element and name. + two terminals at the same point -- three cases in the whole example + corpus. Opening a project gives every terminal of its symbols a uuid + (TerminalUuids::fillMissing()), the same value for all but the second + of such a pair, so in a project this is the fallback for terminals of a + symbol imported since it was opened. Derived values are UUID v5 in a fixed namespace, so they are reproducible without being written to the file, and cannot collide with the v4 uuids @@ -866,23 +867,29 @@ QUuid Terminal::stableUuid() const if (!d->m_uuid.isNull()) { return d->m_uuid; } + return derivedUuid(); +} - //Fixed namespace for terminal identities derived from geometry. - static const QUuid derived_ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}")); +/** + @brief Terminal::derivedUuid + The identity worked out from this terminal's local position and + orientation, whether or not it carries a uuid of its own: the value + stableUuid() gives when it has none, and the one a project gives it + when it is opened (TerminalUuids::fillMissing()). - //Position and orientation only. The name is deliberately excluded: it - //is not stable across a save cycle -- QET rewrites a terminal named - //"_" as unnamed, which would silently change the identity of 1421 of - //industrial.qet's 1790 terminals on the first resave. It is also not - //needed: keying on geometry alone produces exactly the same number of - //collisions across the example corpus, and it means renaming a - //terminal does not change what it is. - const QString key = QStringLiteral("%1|%2|%3") - .arg(d->m_pos.x(), 0, 'f', 4) - .arg(d->m_pos.y(), 0, 'f', 4) - .arg(static_cast(d->m_orientation)); - - return QUuid::createUuidV5(derived_ns, key); + Position and orientation only. The name is deliberately excluded: it + is not stable across a save cycle -- QET rewrites a terminal named "_" + as unnamed, which would silently change the identity of 1421 of + industrial.qet's 1790 terminals on the first resave. It is also not + needed: keying on geometry alone produces exactly the same number of + collisions across the example corpus, and it means renaming a terminal + does not change what it is. +*/ +QUuid Terminal::derivedUuid() const +{ + return TerminalUuids::derived(d->m_pos.x(), + d->m_pos.y(), + static_cast(d->m_orientation)); } QString Terminal::name() const diff --git a/sources/qetgraphicsitem/terminal.h b/sources/qetgraphicsitem/terminal.h index a6eae784c..0bf0f476d 100644 --- a/sources/qetgraphicsitem/terminal.h +++ b/sources/qetgraphicsitem/terminal.h @@ -76,6 +76,7 @@ class Terminal : public QGraphicsObject Element *parentElement () const; QUuid uuid () const; QUuid stableUuid () const; + QUuid derivedUuid () const; QString name () const; QString baseName () const; TerminalData::Type terminalType() const; diff --git a/tests/qttest/tst_terminaluuids.cpp b/tests/qttest/tst_terminaluuids.cpp index 9da458228..6365a57cb 100644 --- a/tests/qttest/tst_terminaluuids.cpp +++ b/tests/qttest/tst_terminaluuids.cpp @@ -67,9 +67,9 @@ class tst_terminaluuids : public QObject QTemporaryDir m_dir; int m_run = 0; - // --info on @p project in a sandbox of its own; returns the number of - // wires loaded and puts what was written on stderr in @p log. - int loadedWires(const QString &project, QString *log) + // The real binary with @p args, in a sandbox of its own (so a running + // QElectroTech cannot answer instead); false if it failed. + bool runQet(const QStringList &args, QByteArray *out, QString *log) { const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); QDir().mkpath(home); @@ -80,15 +80,51 @@ class tst_terminaluuids : public QObject env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); QProcess proc; proc.setProcessEnvironment(env); - proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--info"), project}); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), args); if (!proc.waitForFinished(120000) || proc.exitCode() != 0) - return -1; + return false; *log = QString::fromUtf8(proc.readAllStandardError()); - const QByteArray out = proc.readAllStandardOutput(); + *out = proc.readAllStandardOutput(); + return true; + } + + // --info on @p project; returns the number of wires loaded and puts + // what was written on stderr in @p log. + int loadedWires(const QString &project, QString *log) + { + QByteArray out; + if (!runQet({QStringLiteral("--info"), project}, &out, log)) + return -1; const QJsonDocument json = QJsonDocument::fromJson(out.mid(out.indexOf('{'))); return json.object().value(QStringLiteral("conductors")).toInt(-1); } + // --resave @p in to a new file; returns its path, empty on failure. + QString resave(const QString &in) + { + const QString out = m_dir.filePath(QStringLiteral("resaved%1.qet").arg(m_run)); + QByteArray stdout_; + QString log; + if (!runQet({QStringLiteral("--resave"), in, out}, &stdout_, &log)) + return {}; + return out; + } + + static QDomDocument load(const QString &path) + { + QDomDocument doc; + QFile file(path); + if (file.open(QIODevice::ReadOnly)) + doc.setContent(&file); + return doc; + } + + static QByteArray bytes(const QString &path) + { + QFile f(path); + return f.open(QIODevice::ReadOnly) ? f.readAll() : QByteArray(); + } + // 2612_ats_singlephase.qet with each embedded symbol replaced by a copy // whose terminals carry new uuids, passed through keep() or not. QString replacedSymbols(bool keep_uuids) @@ -221,6 +257,115 @@ private slots: QCOMPARE(uuids(deep), QStringList{"{b}"}); } + // The recipe is Terminal::stableUuid()'s, which the project database + // and the wire uuids worked out from their ends already use: changing + // it would change every such identity in every project. + void derivedRecipeUnchanged() + { + const QUuid ns(QStringLiteral("{6b1f6d1e-6a1a-5f7e-9a3d-9c0a5b2d7e11}")); + QCOMPARE(TerminalUuids::derived(0, 10, 2), + QUuid::createUuidV5(ns, QStringLiteral("0.0000|10.0000|2"))); + QCOMPARE(TerminalUuids::derived(-2.5, 4, 3, 1), + QUuid::createUuidV5(ns, QStringLiteral("-2.5000|4.0000|3|1"))); + } + + // Only terminals without a uuid get one, the derived value; two at one + // point get distinct ones; a value already used in the symbol is never + // given again; symbols in sub-categories are reached. + void fillMissing() + { + QDomDocument doc; + QDomElement root = doc.createElement(QStringLiteral("collection")); + QDomElement sub = doc.createElement(QStringLiteral("category")); + root.appendChild(sub); + const QString taken = TerminalUuids::derived(0, 0, 0).toString(); + const QString own = QStringLiteral("{0f5d4b0c-2f7e-4a55-9a51-8c3a3e1c2d11}"); + QDomElement a = symbol(doc, {{"0", "10", "s"}, + {"5", "0", "e", own}, + {"0", "10", "s"}, + {"0", "0", "n"}, + {"9", "9", "w", taken}}); + QDomElement b = symbol(doc, {{"1", "2", "w"}}); + root.appendChild(a); + sub.appendChild(b); + + QCOMPARE(TerminalUuids::fillMissing(root), 4); + QCOMPARE(uuids(a), (QStringList{ + TerminalUuids::derived(0, 10, 2).toString(), + own, + TerminalUuids::derived(0, 10, 2, 1).toString(), + TerminalUuids::derived(0, 0, 0, 1).toString(), + taken})); + QCOMPARE(uuids(b), QStringList{TerminalUuids::derived(1, 2, 3).toString()}); + QCOMPARE(TerminalUuids::fillMissing(root), 0); + } + + // An example whose symbols have no terminal uuids and whose wires are + // all in the numbered form: once saved, every terminal has a uuid, + // every wire names its ends by uuid, nothing is lost, and saving again + // changes nothing. + void resaveGivesEveryTerminalAUuid() + { + const QString original = QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"); + QString log; + const int wires = loadedWires(original, &log); + QVERIFY(wires > 0); + + const QString saved = resave(original); + QVERIFY2(!saved.isEmpty(), "--resave failed"); + const QDomDocument doc = load(saved); + int terminals = 0; + for (const QDomElement &e : embeddedSymbols(doc)) { + for (const QString &uuid : uuids(e)) { + ++terminals; + QVERIFY2(!QUuid(uuid).isNull(), "a terminal has no uuid"); + } + } + QVERIFY(terminals > 0); + const QDomNodeList conductors = doc.elementsByTagName(QStringLiteral("conductor")); + QCOMPARE(conductors.size(), wires); + for (int i = 0; i < conductors.size(); ++i) { + const QDomElement c = conductors.at(i).toElement(); + QVERIFY2(c.hasAttribute(QStringLiteral("element1")) + && c.hasAttribute(QStringLiteral("element2")), + "a wire is still in the numbered form"); + } + + QCOMPARE(loadedWires(saved, &log), wires); + QVERIFY2(!log.contains(QStringLiteral("not loaded")), "a wire was reported lost"); + const QString again = resave(saved); + QVERIFY2(!again.isEmpty(), "second --resave failed"); + QVERIFY2(bytes(again) == bytes(saved), "the second save changed the file"); + } + + // The uuids written on opening are derived from where each terminal + // is: a wire saved against one still finds its terminal after the + // symbol's definition was replaced by one with other terminal uuids. + void derivedUuidFoundAfterReplacement() + { + const QString saved = resave(QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet")); + QVERIFY(!saved.isEmpty()); + QString log; + const int wires = loadedWires(saved, &log); + QVERIFY(wires > 0); + + QDomDocument doc = load(saved); + for (QDomElement e : embeddedSymbols(doc)) { + const QDomNodeList terminals = e.elementsByTagName(QStringLiteral("terminal")); + for (int i = 0; i < terminals.size(); ++i) + terminals.at(i).toElement().setAttribute(QStringLiteral("uuid"), + QUuid::createUuid().toString()); + } + const QString replaced = m_dir.filePath(QStringLiteral("replaced.qet")); + QFile out(replaced); + QVERIFY(out.open(QIODevice::WriteOnly)); + out.write(doc.toByteArray()); + out.close(); + + QCOMPARE(loadedWires(replaced, &log), wires); + QVERIFY2(!log.contains(QStringLiteral("not loaded")), "a wire was reported lost"); + } + // The real loader, on a project whose 131 wires are saved against // terminal uuids: replaced symbols lose 119 of them unless keep() ran, // and the ones lost are reported. From eb3b10a48efb0c64720a881446a6646a37e0d2e6 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 09:10:38 +1300 Subject: [PATCH 04/45] Tell apart two terminals at one point; qet_diff: same wire in both forms Review of the previous commit: - The load fallback compared a saved uuid with occurrence 0 only, so a wire on the second of two terminals at one point of a symbol was lost once the definition was replaced, and one on the first could go to either of the pair (Element::m_terminals is sorted, not in definition order). Element::parseTerminal() now records each terminal's rank among the terminals of the definition at the same point, derivedUuid() uses it, and fillMissing() starts from the same rank. derivedUuidFoundAfterReplacement runs on perceuse.qet and industrial.qet too: 154/156 and 670/671 wires without the rank, all with it. qet-mcp: the first save of an older project now rewrites its wires from the numbered form to the uuid form, and qet_diff keyed the two forms differently, so an untouched resave showed every wire removed and added (4 failures in test_qet_mcp.py). A uuid end is now resolved to the same key as a numbered one: the terminal's definition position, moved to where the wire docks, is the placed symbol's record. test_conductor_key_same_in_both_forms fails without it; 253/253 pass on this build and on the previous stage's. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 80 ++++++++++++++++++-- misc/qet-mcp/test_qet_mcp.py | 24 ++++++ sources/ElementsCollection/terminaluuids.cpp | 23 +++--- sources/qetgraphicsitem/element.cpp | 10 +++ sources/qetgraphicsitem/terminal.cpp | 18 ++++- sources/qetgraphicsitem/terminal.h | 4 + tests/qttest/tst_terminaluuids.cpp | 14 +++- 7 files changed, 156 insertions(+), 17 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 303a806d3..09d84c384 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -126,12 +126,56 @@ def _wires(diagram: ET.Element): def _conductors(root: ET.Element): + definitions = _definition_terminals(root) for i, d in _folios(root): - index = _terminal_index(d) + index = _terminal_index(d, definitions) for c in _wires(d): yield i, c, index +def _uuid_key(value: str) -> str: + return (value or "").strip().strip("{}").lower() + + +# Orientation as a placed symbol's record writes it (an int, +# Qet::Orientation) or as a definition does (n/e/s/w). +_ORIENTATIONS = {"n": 0, "e": 1, "s": 2, "w": 3, "0": 0, "1": 1, "2": 2, "3": 3} + +# Where QElectroTech docks a wire, relative to the terminal's position in +# its definition (Terminal's constructor, Terminal::terminalSize = 4). A +# placed symbol's record is written at that point. +_DOCK_OFFSET = {0: (0.0, 4.0), 1: (-4.0, 0.0), 2: (0.0, -4.0), 3: (4.0, 0.0)} + + +def _definition_terminals(root: ET.Element) -> dict: + """Map each symbol stored in the project ("embed://" + its path in the + ) to its terminals: {terminal uuid: (x, y, orientation)}, + the position being the one in the definition.""" + out = {} + + def walk(node, path): + for child in node: + if child.tag == "category": + walk(child, path + [child.get("name", "")]) + elif child.tag == "element": + terminals = {} + for t in child.findall("definition/description/terminal"): + try: + terminals[_uuid_key(t.get("uuid"))] = ( + float(t.get("x")), float(t.get("y")), + _ORIENTATIONS.get((t.get("orientation") or "n")[:1], 0)) + except (TypeError, ValueError): + continue + terminals.pop("", None) + if terminals: + out["embed://" + "/".join(path + [child.get("name", "")])] = terminals + + collection = root.find("collection") + if collection is not None: + walk(collection, []) + return out + + def _element_row(folio: int, el: ET.Element) -> dict: info = _element_info(el) etype = el.get("type", "") @@ -147,7 +191,7 @@ def _element_row(folio: int, el: ET.Element) -> dict: } -def _terminal_index(diagram: ET.Element) -> dict: +def _terminal_index(diagram: ET.Element, definitions: dict | None = None) -> dict: """Map a folio's terminal ids to an identity that survives a save. A conductor names its ends with terminal1/terminal2, which are plain @@ -170,6 +214,17 @@ def _terminal_index(diagram: ET.Element) -> dict: Conductors in the corpus carry no element1/element2 attribute -- 0 of 47 in ArduinoLCD.qet, 0 of 67 in 741.qet -- so this mapping has to be built from the elements rather than read off the conductor. + + A conductor can also name its ends by terminal uuid (element1 + + terminal1), and QElectroTech writes that form as soon as the terminal + has a uuid -- which, since a project gives every terminal one on + opening, is the first save of any older file. So the same untouched + conductor is written in the numbered form before a save and the uuid + form after it. With @p definitions (from _definition_terminals()), a + uuid end is resolved too, keyed (element uuid, terminal uuid), to the + very same identity as the numbered end: the terminal's definition + position, moved to where the wire docks, is where the placed symbol's + record is. """ index = {} for el in diagram.iter("element"): @@ -184,12 +239,26 @@ def _terminal_index(diagram: ET.Element) -> dict: # marked with a "#" so the caller can see the diff is on the # unstable footing that file forces. continue + records = [] for t in el.iter("terminal"): tid = t.get("id") if tid is None: continue - index[tid] = (f"{uuid}@{t.get('x','?')},{t.get('y','?')}" - f",{t.get('orientation','?')}") + key = (f"{uuid}@{t.get('x','?')},{t.get('y','?')}" + f",{t.get('orientation','?')}") + index[tid] = key + records.append((t, key)) + for tuuid, (x, y, o) in (definitions or {}).get(el.get("type", ""), {}).items(): + dx, dy = _DOCK_OFFSET[o] + for t, key in records: + try: + if (abs(float(t.get("x")) - (x + dx)) < 1e-6 + and abs(float(t.get("y")) - (y + dy)) < 1e-6 + and _ORIENTATIONS.get((t.get("orientation") or "")[:1]) == o): + index[(_uuid_key(uuid), tuuid)] = key + break + except (TypeError, ValueError): + continue return index @@ -214,7 +283,8 @@ def _conductor_key(folio: int, c: ET.Element, index: dict) -> str: tid = c.get(term_attr, "?") owner = c.get(elem_attr) if owner: - ends.append(f"{owner}/{tid or c.get(name_attr, '?')}") + ends.append(index.get((_uuid_key(owner), _uuid_key(tid))) + or f"{owner}/{tid or c.get(name_attr, '?')}") else: # An id with no element behind it stays visible as itself # rather than silently collapsing conductors onto one key. diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index a32f7bbee..fdac11f55 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -901,6 +901,30 @@ class ReadToolContracts(unittest.TestCase): r = tool(str(big)) self.assertEqual((r["count"], r["truncated"], len(r[key])), (201, True, 200)) + def test_conductor_key_same_in_both_forms(self): + # A wire in the numbered form before a save and the uuid form after + # it (the first save of an older project) is the same wire: both + # ends resolve to the placed symbol's terminal record. The record is + # where the wire docks, 4 from the definition position. + root = ET.fromstring( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '') + numbered, by_uuid = [m._conductor_row(i, c, ix)["key"] + for i, c, ix in m._conductors(root)] + self.assertEqual(numbered, "1:{E}@0,-4,2--{E}@6,0,1") + self.assertEqual(by_uuid, numbered) + def test_conductor_row_without_an_index(self): c = ET.fromstring('') self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8") diff --git a/sources/ElementsCollection/terminaluuids.cpp b/sources/ElementsCollection/terminaluuids.cpp index 451a8f7a3..fe9141f47 100644 --- a/sources/ElementsCollection/terminaluuids.cpp +++ b/sources/ElementsCollection/terminaluuids.cpp @@ -42,6 +42,15 @@ QList terminalsOf(const QDomElement &collection_element) return terminals; } + //Place and orientation, "10" and "10.0" being the same place +QString terminalPlace(const QDomElement &terminal) +{ + return QStringLiteral("%1|%2|%3") + .arg(QString::number(terminal.attribute(QStringLiteral("x")).toDouble()), + QString::number(terminal.attribute(QStringLiteral("y")).toDouble()), + terminal.attribute(QStringLiteral("orientation"))); +} + //Qet::orientationFromString(), without pulling in qet.cpp int orientationOf(const QDomElement &terminal) { @@ -65,7 +74,11 @@ int fillDefinition(const QDomElement &collection_element) } int filled = 0; + QHash seen_at; for (QDomElement t : terminals) { + //Same count as Terminal::setPlaceRank(): every terminal before + //this one at the same point, with or without a uuid + const int rank = seen_at[terminalPlace(t)]++; if (!QUuid(t.attribute(QStringLiteral("uuid"))).isNull()) { continue; } @@ -73,7 +86,7 @@ int fillDefinition(const QDomElement &collection_element) const qreal y = t.attribute(QStringLiteral("y")).toDouble(); const int orientation = orientationOf(t); QUuid uuid; - for (int occurrence = 0 ; uuid.isNull() || taken.contains(uuid) ; ++occurrence) { + for (int occurrence = rank ; uuid.isNull() || taken.contains(uuid) ; ++occurrence) { uuid = TerminalUuids::derived(x, y, orientation, occurrence); } taken << uuid; @@ -98,14 +111,6 @@ int fillDirectory(const QDomElement &directory) return filled; } - //Place and orientation, "10" and "10.0" being the same place -QString terminalPlace(const QDomElement &terminal) -{ - return QStringLiteral("%1|%2|%3") - .arg(QString::number(terminal.attribute(QStringLiteral("x")).toDouble()), - QString::number(terminal.attribute(QStringLiteral("y")).toDouble()), - terminal.attribute(QStringLiteral("orientation"))); -} } /** diff --git a/sources/qetgraphicsitem/element.cpp b/sources/qetgraphicsitem/element.cpp index eb9dbfbe2..a91e78433 100644 --- a/sources/qetgraphicsitem/element.cpp +++ b/sources/qetgraphicsitem/element.cpp @@ -697,6 +697,16 @@ Terminal *Element::parseTerminal(const QDomElement &dom_element) } Terminal *new_terminal = new Terminal(data, this); + //Terminals are parsed in the order of the definition, and the list + //is not kept in that order (sort below) + int rank = 0; + for (Terminal *t : std::as_const(m_terminals)) { + if (t->dock_elmt_ == new_terminal->dock_elmt_ + && t->orientation() == new_terminal->orientation()) { + ++rank; + } + } + new_terminal->setPlaceRank(rank); m_terminals << new_terminal; connect(new_terminal, &Terminal::conductorWasAdded, this, &Element::updateConductorTexts); diff --git a/sources/qetgraphicsitem/terminal.cpp b/sources/qetgraphicsitem/terminal.cpp index 5c1a511c4..e3ba0198d 100644 --- a/sources/qetgraphicsitem/terminal.cpp +++ b/sources/qetgraphicsitem/terminal.cpp @@ -884,12 +884,28 @@ QUuid Terminal::stableUuid() const needed: keying on geometry alone produces exactly the same number of collisions across the example corpus, and it means renaming a terminal does not change what it is. + + A second terminal at the same point with the same orientation gets the + next occurrence (see setPlaceRank()), as TerminalUuids::fillMissing() + gives it, so the two terminals of such a pair are told apart. */ QUuid Terminal::derivedUuid() const { return TerminalUuids::derived(d->m_pos.x(), d->m_pos.y(), - static_cast(d->m_orientation)); + static_cast(d->m_orientation), + m_place_rank); +} + +/** + @brief Terminal::setPlaceRank + @param rank : how many terminals of the definition, before this one in + document order, sit at the same point with the same orientation. + Set by Element::parseTerminal(). +*/ +void Terminal::setPlaceRank(int rank) +{ + m_place_rank = rank; } QString Terminal::name() const diff --git a/sources/qetgraphicsitem/terminal.h b/sources/qetgraphicsitem/terminal.h index 0bf0f476d..b491f1ec2 100644 --- a/sources/qetgraphicsitem/terminal.h +++ b/sources/qetgraphicsitem/terminal.h @@ -77,6 +77,7 @@ class Terminal : public QGraphicsObject QUuid uuid () const; QUuid stableUuid () const; QUuid derivedUuid () const; + void setPlaceRank (int rank); QString name () const; QString baseName () const; TerminalData::Type terminalType() const; @@ -143,6 +144,9 @@ class Terminal : public QGraphicsObject Terminal *m_previous_terminal = nullptr; /// Whether the mouse pointer is hovering the terminal bool m_hovered = false; + /// How many terminals of the definition, before this one, sit at + /// the same point with the same orientation; see derivedUuid() + int m_place_rank = 0; /// Color used for the hover effect QColor m_hovered_color = Terminal::neutralColor; diff --git a/tests/qttest/tst_terminaluuids.cpp b/tests/qttest/tst_terminaluuids.cpp index 6365a57cb..b9a7fe1fd 100644 --- a/tests/qttest/tst_terminaluuids.cpp +++ b/tests/qttest/tst_terminaluuids.cpp @@ -341,9 +341,19 @@ private slots: // The uuids written on opening are derived from where each terminal // is: a wire saved against one still finds its terminal after the // symbol's definition was replaced by one with other terminal uuids. + // perceuse.qet and industrial.qet have wires on the second of two + // terminals at one point of a symbol, which get the next occurrence. + void derivedUuidFoundAfterReplacement_data() + { + QTest::addColumn("project"); + for (const char *name : {"tremie_vibrante.qet", "perceuse.qet", "industrial.qet"}) + QTest::newRow(name) << QStringLiteral(QET_EXAMPLES_DIR "/") + QLatin1String(name); + } + void derivedUuidFoundAfterReplacement() { - const QString saved = resave(QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet")); + QFETCH(QString, project); + const QString saved = resave(project); QVERIFY(!saved.isEmpty()); QString log; const int wires = loadedWires(saved, &log); @@ -356,7 +366,7 @@ private slots: terminals.at(i).toElement().setAttribute(QStringLiteral("uuid"), QUuid::createUuid().toString()); } - const QString replaced = m_dir.filePath(QStringLiteral("replaced.qet")); + const QString replaced = m_dir.filePath(QStringLiteral("replaced%1.qet").arg(m_run)); QFile out(replaced); QVERIFY(out.open(QIODevice::WriteOnly)); out.write(doc.toByteArray()); From 8b54ea400b9cdf649c1ba1503ba3b443897841b3 Mon Sep 17 00:00:00 2001 From: Kellermorph Date: Mon, 28 Sep 2026 22:38:41 +0200 Subject: [PATCH 05/45] Fix material list reading, selection and article fields Follow-up to #1088, covering its review comments and one behaviour change found while testing the feature: - MaterialList::load() names the columns from the machine key line when the file has one, instead of from the translated label line above it. A catalogue written in another language fills the element fields again, and saving no longer replaces the key line with labels. - The entry picked after "New entry" is found by comparing the columns one by one instead of comparing the two maps as a whole: the entry form leaves the empty columns out, so the record never matched the line that had just been written and the search was cleared for nothing. The search is now only given up when it really hides the new line. - Applying a catalogue entry pushes an undo command only when the live edit is on. In the properties window, where there is none, the fields wait for "Apply", so "Cancel" gives the element its own values back instead of leaving the picked part in place. - Cells a file holds past the header are kept in MaterialRecord::extra and written back, so appending an article never shortens a line. - An empty cell of a column describing the article itself (MaterialList:: isArticleBound: description, designation, manufacturer, order number, supplier, model, ratings, dimensions, auxiliary block) clears the field, so an element never keeps the manufacturer of the part picked before. An empty cell of any other column (function, comment, notes, plant, location, quantity, unity), and any column the file does not hold at all, leaves the field alone. - Comments left where the review asked for them: the corner button lookup, the ';' separator fallback, the search filter cost. --- sources/materiallist/materiallist.cpp | 72 ++++++++++++++++++- sources/materiallist/materiallist.h | 5 ++ .../materiallist/materialselectiondialog.cpp | 46 +++++++++++- sources/ui/elementinfowidget.cpp | 34 +++++++-- 4 files changed, 148 insertions(+), 9 deletions(-) diff --git a/sources/materiallist/materiallist.cpp b/sources/materiallist/materiallist.cpp index e84b1ec1c..b9f8a2b8d 100644 --- a/sources/materiallist/materiallist.cpp +++ b/sources/materiallist/materiallist.cpp @@ -278,6 +278,44 @@ QString MaterialList::elementInfoKey(const QString &column, int block) return column + QStringLiteral("_auxiliary%1").arg(block); } +/** + @brief MaterialList::isArticleBound + Tell whether a column describes the article itself, rather than what + the element does or where it stands. + + An empty cell of such a column clears the field it feeds when the + entry is applied: an element cannot carry the manufacturer of two + parts at once, so the value of the article picked before has to go. + Every other column (function, comment, notes, plant, location, and + quantity and unity, which say how many pieces this element takes and + in which unit) is left alone by an empty cell, and so is a column the + file does not hold at all: a file which mentions nothing about a + field is no reason to empty it. + @param column a column of the material file + @return true when an empty cell of that column clears the field +*/ +bool MaterialList::isArticleBound(const QString &column) +{ + static const QStringList article_columns = { + QStringLiteral("description"), + QStringLiteral("designation"), + QStringLiteral("manufacturer"), + QStringLiteral("manufacturer_reference"), + QStringLiteral("machine_manufacturer_reference"), + QStringLiteral("supplier"), + QStringLiteral("model"), + QStringLiteral("category"), + QStringLiteral("voltage_rating"), + QStringLiteral("current_rating"), + QStringLiteral("width"), + QStringLiteral("height"), + QStringLiteral("depth"), + QStringLiteral("auxiliary") + }; + + return article_columns.contains(column); +} + /** @brief MaterialList::translatedColumn @param column a column of the material file @@ -443,6 +481,10 @@ QChar MaterialList::detectSeparator(const QString &first_line) const int commas = first_line.count(QLatin1Char(',')); if (semicolons == 0 && tabs == 0 && commas == 0) { + //A header holding no separator character at all is a file of a + //single column : falling back on the semicolon is deliberate, + //it is the one QElectroTech writes itself, and such a file + //needs no detection anyway. Leave this alone. return QLatin1Char(';'); } if (tabs >= semicolons && tabs >= commas && tabs > 0) { @@ -616,14 +658,25 @@ bool MaterialList::load(const QString &path, MaterialListData *data, QString *er //The label line is followed by the machine header QElectroTech //writes itself : the canonical names, so that the file is read the - //same way whatever the language it was written in. + //same way whatever the language it was written in. That machine + //line is the one naming the columns when it is there : the label + //line above it may be written in another language than the one + //running now, and would then match nothing. The label line stays + //on disk, it is only what the user reads. + QStringList machine_line; if (!rows.isEmpty() && isMachineHeaderLine(header, rows.first(), alias_map)) { - rows.takeFirst(); + machine_line = rows.takeFirst(); } QStringList used; - for (const QString &cell : header) + for (int i = 0; i < header.size(); ++i) { + //An empty machine cell means the column is named by the label + //above it (a column added by hand shows up in both lines). + QString cell = header.at(i); + if (i < machine_line.size() && !machine_line.at(i).isEmpty()) { + cell = machine_line.at(i); + } const QString column = canonicalColumn(cell, used, alias_map); used.append(column); data->columns.append(column); @@ -640,6 +693,16 @@ bool MaterialList::load(const QString &path, MaterialListData *data, QString *er empty = false; } record.setValue(data->columns.at(i), value); + } + //Cells written past the header are not thrown away : they are + //written back the way they were read, and they are content too, + //so a line only they fill is kept as well. + for (int i = data->columns.size(); i < row.size(); ++i) + { + if (!row.at(i).isEmpty()) { + empty = false; + } + record.extra.append(row.at(i)); } if (!empty) { data->records.append(record); @@ -673,6 +736,9 @@ bool MaterialList::writeFile(const QString &path, const MaterialListData &data, for (const QString &column : data.columns) { row.append(record.value(column)); } + //The cells the file holds past the header, if any, follow the + //columns : a line wider than the header stays that wide. + row.append(record.extra); rows.append(row); } diff --git a/sources/materiallist/materiallist.h b/sources/materiallist/materiallist.h index 66b5cda77..6e219c586 100644 --- a/sources/materiallist/materiallist.h +++ b/sources/materiallist/materiallist.h @@ -37,6 +37,10 @@ struct MaterialRecord { QMap values; + //Cells written after the last column of the header, kept as they + //are so that appending an article never shortens a line the user + //wrote wider than the header itself. + QStringList extra; QString value(const QString &column) const {return values.value(column);} void setValue(const QString &column, const QString &value) {values.insert(column, value);} @@ -91,6 +95,7 @@ class MaterialList static QStringList defaultColumns(); static QStringList columnsForBlock(int block); static QString elementInfoKey(const QString &column, int block); + static bool isArticleBound(const QString &column); static QString translatedColumn(const QString &column); static QStringList translatedHeader(const QStringList &columns); diff --git a/sources/materiallist/materialselectiondialog.cpp b/sources/materiallist/materialselectiondialog.cpp index d7265de7e..2c4c17f9e 100644 --- a/sources/materiallist/materialselectiondialog.cpp +++ b/sources/materiallist/materialselectiondialog.cpp @@ -173,6 +173,11 @@ void MaterialFilterProxy::setTokens(const QStringList &tokens) A row is kept when each token is found in at least one of its cells : the words may be spread over different columns, as a search for "Hilfsschalter Schneider" expects. + + Every token walks every cell of the row, which means the whole file + on every keystroke : well within reach for a catalogue, to be + revisited before someone imports a supplier export of tens of + thousands of lines. @param source_row @param source_parent @return @@ -235,6 +240,11 @@ MaterialSelectionDialog::MaterialSelectionDialog(const QString &path, //The row numbers are the order of the file : clicking them, or the //corner just above them, sorts the table by them, which means the //table is no longer sorted at all. + // + //QTableView keeps that corner button to itself, so the only way to + //reach it is the name Qt gives that private class. Nothing breaks + //if the name ever changes : the row numbers below stay connected + //and only this shortcut disappears. for (QAbstractButton *button : ui->m_table_view->findChildren()) { if (button->inherits("QTableCornerButton")) { @@ -511,6 +521,40 @@ MaterialRecord MaterialSelectionDialog::selectedRecord() const return m_model->record(source.row()); } +/** + @brief sameEntry + Tell whether two records describe the same article of the file. + + The maps are not compared as they are: an entry built by the entry + form leaves the empty columns out, load() gives every column to every + record, and a file may hold spaces the form has trimmed. Comparing + per column makes both shapes say the same thing. Cells the file holds + past the header are not part of it: they say nothing about which + article a line is. + @param lhs + @param rhs + @return true when both records describe the same article +*/ +static bool sameEntry(const MaterialRecord &lhs, const MaterialRecord &rhs) +{ + QStringList columns = lhs.values.keys(); + for (const QString &column : rhs.values.keys()) + { + if (!columns.contains(column)) { + columns.append(column); + } + } + + for (const QString &column : columns) + { + if (lhs.value(column).trimmed() != rhs.value(column).trimmed()) { + return false; + } + } + + return true; +} + /** @brief MaterialSelectionDialog::selectRecord Select the row holding that record, scrolling to it. @@ -525,7 +569,7 @@ bool MaterialSelectionDialog::selectRecord(const MaterialRecord &record) //the line which was just written. for (int i = records.size() - 1; i >= 0; --i) { - if (records.at(i) == record) + if (sameEntry(records.at(i), record)) { row = i; break; diff --git a/sources/ui/elementinfowidget.cpp b/sources/ui/elementinfowidget.cpp index f009bb374..78aab5395 100644 --- a/sources/ui/elementinfowidget.cpp +++ b/sources/ui/elementinfowidget.cpp @@ -438,9 +438,14 @@ void ElementInfoWidget::materialFromFile(int block) Write a catalogue entry into the fields of one block. Only the fields of that block are touched: applied to an auxiliary - article, the entry never reaches the main article. And a cell which is - empty in the file leaves the current value alone, so picking an entry - describing only the order reference never wipes a comment. + article, the entry never reaches the main article. + + An empty cell of a column describing the article (MaterialList:: + isArticleBound) clears the field: keeping the manufacturer of the + article picked before would describe a part which does not exist. + An empty cell of any other column, and any column the file does not + hold at all, leaves the field alone, so picking an entry describing + only the order reference never wipes a comment. @param record the entry taken from the material file @param block 0 for the main article, 1 to 4 for an auxiliary article */ @@ -459,8 +464,19 @@ void ElementInfoWidget::applyMaterialRecord(const MaterialRecord &record, int bl for (const QString &column : MaterialList::columnsForBlock(block)) { + //A column the file does not hold says nothing about the + //article: the field of the element stays as it is. + if (!record.values.contains(column)) { + continue; + } + const QString value = record.value(column); - if (value.isEmpty()) { + + //An empty cell only matters for the columns describing the + //article itself, where the previous value belongs to another + //part. For the others (function, comment, quantity...) an + //empty cell means the file has nothing to say about them. + if (value.isEmpty() && !MaterialList::isArticleBound(column)) { continue; } @@ -478,7 +494,15 @@ void ElementInfoWidget::applyMaterialRecord(const MaterialRecord &record, int bl enableLiveEdit(); } - apply(); + //Outside of the live edit the fields only carry the article: it + //is the properties window which applies them when the user presses + //"Apply" (ElementPropertiesWidget::apply() takes the undo command + //from these very fields). Applying right away would push the change + //on the undo stack before he has decided anything, and "Cancel" + //would give the fields back but never the element. + if (live_edit) { + apply(); + } } /** From 34a2ceff70511c837c967c517bb5c10e32ffd456 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 09:55:02 +1300 Subject: [PATCH 06/45] Fix #1117: element icon vanishes from the collection after saving Saving an element from the element editor calls ElementsCollectionWidget::locationWasSaved(), which runs clearData() on the panel item: the icon is set to a null QIcon. The icon only comes back through FileElementCollectionItem::setUpIcon(), but since the #633 recursion guard that returns for good once m_icon_initialized is set, and nothing ever reset it. The row stayed without an icon until the whole collection was reloaded. #1008 refreshed the picture caches on save, but no one asked them for the new picture, so it could not fix this. Resetting the flag in clearData() lets the next paint rebuild the icon, which then comes from those refreshed caches, so the panel shows the new drawing. The flag is reset after the base clearData(): its setIcon() emits dataChanged(), which re-enters setUpIcon() and must still return early. setUpIcon() sets the flag before its own setIcon(), so the #633 guard is unchanged. The same reset should also bring back a folder's icon after editing its properties (editDirectory() calls clearData() too); read, not tested. Project collection items were never affected: their setUpIcon() guards on icon().isNull(). Co-Authored-By: Claude Opus 5.5 --- .../fileelementcollectionitem.cpp | 16 ++++++++++++++++ .../fileelementcollectionitem.h | 1 + 2 files changed, 17 insertions(+) diff --git a/sources/ElementsCollection/fileelementcollectionitem.cpp b/sources/ElementsCollection/fileelementcollectionitem.cpp index 1c8dc410b..7b9d3ea98 100644 --- a/sources/ElementsCollection/fileelementcollectionitem.cpp +++ b/sources/ElementsCollection/fileelementcollectionitem.cpp @@ -480,6 +480,22 @@ void FileElementCollectionItem::setUpIcon() } } +/** + @brief FileElementCollectionItem::clearData + Reset the data, and let setUpIcon() build the icon again. + Without this the guard in setUpIcon() keeps the icon cleared here + empty until the whole collection is reloaded, e.g. after an element + is saved from the element editor (see locationWasSaved()). +*/ +void FileElementCollectionItem::clearData() +{ + // Reset the flag only after the base class has cleared the icon: + // its setIcon() emits dataChanged(), which re-enters setUpIcon(), + // and that call must still see the flag set and return early. + ElementCollectionItem::clearData(); + m_icon_initialized = false; +} + /** @brief FileElementCollectionItem::setPathName Set the name of this item in the file system path. diff --git a/sources/ElementsCollection/fileelementcollectionitem.h b/sources/ElementsCollection/fileelementcollectionitem.h index 6cb78aa08..68cc6d5ba 100644 --- a/sources/ElementsCollection/fileelementcollectionitem.h +++ b/sources/ElementsCollection/fileelementcollectionitem.h @@ -55,6 +55,7 @@ class FileElementCollectionItem : public ElementCollectionItem void setUpData() override; void setUpIcon() override; + void clearData() override; private: void setPathName(const QString& path_name, From 08bce54a2f220520fb8a86514c77f44c0eee69cf Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 09:59:21 +1300 Subject: [PATCH 07/45] Fix #1113: custom title-block variables in Find/Replace and unset placeholders Two problems with a custom variable newly added to a title-block template: 1. Find/Replace > Folio > Custom showed an empty table, so the user had to know and type each variable name. It now lists every custom variable the folios already carry plus those their templates use, with empty values. An empty value now means "leave unchanged", like every field of the main tab; only the variables actually filled in are written to the folios. 2. The title block showed the variable's own name ("%doc-type") until the folio's properties were opened, because interpreteVariables() only replaces names present in the context. Placeholders in the template text that no key resolves now render blank, as auto-added unset ones already did since #973. Only the template's own text is considered, so a value that contains "%something" is never touched. Co-Authored-By: Claude Opus 5.5 --- .../searchandreplaceworker.cpp | 15 ++++++- .../ui/searchandreplacewidget.cpp | 22 ++++++++++- sources/titleblocktemplate.cpp | 39 +++++++++++++++++-- sources/titleblocktemplate.h | 2 +- sources/ui/titleblockpropertieswidget.cpp | 16 ++++++-- sources/ui/titleblockpropertieswidget.h | 4 ++ 6 files changed, 89 insertions(+), 9 deletions(-) diff --git a/sources/SearchAndReplace/searchandreplaceworker.cpp b/sources/SearchAndReplace/searchandreplaceworker.cpp index 9f74248d7..28c053e4a 100644 --- a/sources/SearchAndReplace/searchandreplaceworker.cpp +++ b/sources/SearchAndReplace/searchandreplaceworker.cpp @@ -76,7 +76,20 @@ void SearchAndReplaceWorker::replaceDiagram(QList diagram_list) } } - new_properties.context.add(m_titleblock_properties.context); + // The replace dialog lists every custom variable of the folios + // (#1113), so an empty value means "leave unchanged", exactly like + // the fields of the main tab. + const DiagramContext &change = m_titleblock_properties.context; + for (const QString &key : change.keys()) { + const QString value = change.value(key).toString(); + if (value.isEmpty()) { + continue; + } + new_properties.context.addValue( + key, + applyChange(new_properties.context.value(key).toString(), + value)); + } if (old_propertie != new_properties) { project->undoStack()->push(new ChangeTitleBlockCommand(d, old_propertie, new_properties)); diff --git a/sources/SearchAndReplace/ui/searchandreplacewidget.cpp b/sources/SearchAndReplace/ui/searchandreplacewidget.cpp index af229b0c5..998e1b2ad 100644 --- a/sources/SearchAndReplace/ui/searchandreplacewidget.cpp +++ b/sources/SearchAndReplace/ui/searchandreplacewidget.cpp @@ -30,6 +30,7 @@ #include "../../qeticons.h" #include "../../qetinformation.h" #include "../../qetproject.h" +#include "../../ui/titleblockpropertieswidget.h" #include "replaceadvanceddialog.h" #include "replaceconductordialog.h" #include "replaceelementdialog.h" @@ -1109,7 +1110,26 @@ void SearchAndReplaceWidget::on_m_previous_pb_clicked() void SearchAndReplaceWidget::on_m_folio_pb_clicked() { ReplaceFolioDialog *dialog = new ReplaceFolioDialog(this); - dialog->setTitleBlockProperties(m_worker.m_titleblock_properties); + + // List the custom variables the folios already use, so the user only + // has to type a value instead of guessing each name (#1113). + TitleBlockProperties properties = m_worker.m_titleblock_properties; + for (const QPointer &diagram : std::as_const(m_diagram_hash)) + { + if (!diagram) { + continue; + } + for (const QString &key : + diagram->border_and_titleblock.exportTitleBlock().context.keys()) { + if (!properties.context.contains(key)) { + properties.context.addValue(key, QString()); + } + } + TitleBlockPropertiesWidget::addTemplateVariables( + properties.context, + diagram->border_and_titleblock.titleBlockTemplate()); + } + dialog->setTitleBlockProperties(properties); int result = dialog->exec(); if (result == QDialogButtonBox::AcceptRole) diff --git a/sources/titleblocktemplate.cpp b/sources/titleblocktemplate.cpp index 79d6b0adf..69214702e 100644 --- a/sources/titleblocktemplate.cpp +++ b/sources/titleblocktemplate.cpp @@ -27,6 +27,7 @@ #include #include +#include /** @brief TitleBlockTemplate::TitleBlockTemplate Constructor @@ -1818,14 +1819,46 @@ QString TitleBlockTemplate::interpreteVariables( const QString &string, const DiagramContext &diagram_context) const { + // A variable nobody has given a value to yet -- e.g. one just added to + // the template, which is not in the folio's Custom tab until its + // properties are opened (#1113) -- must render blank rather than as its + // own name, the same as an auto-added but unset one already does (#973). + // Collect those from the template text before substituting, so a value + // that happens to contain "%something" is never touched. + // A bare "%name" is unset only if no key is a prefix of it, because the + // substitution below replaces "%key" wherever it appears. + static const QRegularExpression rx( + QStringLiteral("%\\{([a-z0-9_-]+)\\}|%([a-z0-9_-]+)")); + const QStringList keys = + diagram_context.keys(DiagramContext::DecreasingLength); + QStringList unset; + auto it = rx.globalMatch(string); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + const QString name = m.captured(1).isEmpty() + ? m.captured(2) : m.captured(1); + bool known = diagram_context.contains(name); + if (!known && m.captured(1).isEmpty()) { + for (const QString &key : keys) { + if (name.startsWith(key)) { known = true; break; } + } + } + if (!known) unset << m.captured(0); + } + QString interpreted_string = string; - foreach (QString key, - diagram_context.keys(DiagramContext::DecreasingLength)) { + foreach (QString key, keys) { interpreted_string.replace("%{" % key % "}", diagram_context[key].toString()); interpreted_string.replace("%" % key, diagram_context[key].toString()); } + std::sort(unset.begin(), unset.end(), + [](const QString &a, const QString &b) { + return a.length() > b.length(); + }); + for (const QString &placeholder : unset) + interpreted_string.remove(placeholder); return(interpreted_string); } @@ -1834,7 +1867,7 @@ QString TitleBlockTemplate::interpreteVariables( Get list of variables @return The list of string with variables */ -QStringList TitleBlockTemplate::listOfVariables() +QStringList TitleBlockTemplate::listOfVariables() const { QStringList list; // Match both the braced "%{name}" form and the bare "%name" form diff --git a/sources/titleblocktemplate.h b/sources/titleblocktemplate.h index a7f6c9686..8bc1821d6 100644 --- a/sources/titleblocktemplate.h +++ b/sources/titleblocktemplate.h @@ -188,7 +188,7 @@ class TitleBlockTemplate : public QObject { void applyCellSpan(TitleBlockCell *); void applyRowColNums(); void rowColsChanged(); - QStringList listOfVariables(); + QStringList listOfVariables() const; protected: void loadInformation(const QDomElement &); diff --git a/sources/ui/titleblockpropertieswidget.cpp b/sources/ui/titleblockpropertieswidget.cpp index b3ff972cc..929b557c2 100644 --- a/sources/ui/titleblockpropertieswidget.cpp +++ b/sources/ui/titleblockpropertieswidget.cpp @@ -483,13 +483,23 @@ TitleBlockTemplate *TitleBlockPropertiesWidget::templateForIndex(int index) cons Add to @p context every CUSTOM variable used by the currently selected template that is not already present, with an empty value — so the user only has to fill in the values instead of declaring the variables (#271). - The standard fields (title, author, date, …) are handled by their own - widgets and are skipped. Existing values in @p context are preserved. */ void TitleBlockPropertiesWidget::addTemplateVariables( DiagramContext &context, int index) const { - TitleBlockTemplate *tpl = templateForIndex(index); + addTemplateVariables(context, templateForIndex(index)); +} + +/** + @brief TitleBlockPropertiesWidget::addTemplateVariables + Add to @p context every CUSTOM variable used by @p tpl that is not already + present, with an empty value. + The standard fields (title, author, date, …) are handled by their own + widgets and are skipped. Existing values in @p context are preserved. +*/ +void TitleBlockPropertiesWidget::addTemplateVariables( + DiagramContext &context, const TitleBlockTemplate *tpl) +{ if (!tpl) return; // Variables rendered from the dedicated standard-field widgets; they must diff --git a/sources/ui/titleblockpropertieswidget.h b/sources/ui/titleblockpropertieswidget.h index 00e8c1758..0622da8a5 100644 --- a/sources/ui/titleblockpropertieswidget.h +++ b/sources/ui/titleblockpropertieswidget.h @@ -71,6 +71,10 @@ class TitleBlockPropertiesWidget : public QWidget void setTitleBlockTemplatesVisible(const bool &visible); void setReadOnly (const bool &ro); + static void addTemplateVariables( + DiagramContext &context, + const TitleBlockTemplate *tpl); + private: void addCollection ( TitleBlockTemplatesCollection *tbt_collection); From 7facb1cbf8619e2ca3c46fc145acc0684c0a93c3 Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Mon, 28 Sep 2026 23:00:58 +0200 Subject: [PATCH 08/45] Fix cast which becomes a warning in C++20. --- sources/materiallist/materialselectiondialog.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/materiallist/materialselectiondialog.cpp b/sources/materiallist/materialselectiondialog.cpp index d7265de7e..ba1aabfb5 100644 --- a/sources/materiallist/materialselectiondialog.cpp +++ b/sources/materiallist/materialselectiondialog.cpp @@ -373,7 +373,7 @@ bool MaterialSelectionDialog::eventFilter(QObject *watched, QEvent *event) static int wheelAmount(int delta, bool counting_items) { if (counting_items) { - return 3 * qRound(double(delta) / QWheelEvent::DefaultDeltasPerStep); + return 3 * qRound(static_cast(delta) / static_cast(QWheelEvent::DefaultDeltasPerStep)); } return delta; } From 574d6dcd56f15e6e08560077bb429a6d3f0a6ae2 Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Mon, 28 Sep 2026 23:01:42 +0200 Subject: [PATCH 09/45] Fix captor operator [=] which needs to be written explciitly [this] in C++20. --- .../TerminalStrip/ui/freeterminaleditor.cpp | 2 +- .../TerminalStrip/ui/terminalstripeditor.cpp | 2 +- .../TerminalStrip/ui/terminalstripmodel.cpp | 2 +- sources/diagramview.cpp | 2 +- sources/editor/ui/dynamictextfieldeditor.cpp | 24 +++++++++---------- sources/qetdiagrameditor.cpp | 6 ++--- sources/qetproject.cpp | 2 +- sources/ui/linksingleelementwidget.cpp | 4 ++-- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/sources/TerminalStrip/ui/freeterminaleditor.cpp b/sources/TerminalStrip/ui/freeterminaleditor.cpp index 70ebea752..e9863f448 100644 --- a/sources/TerminalStrip/ui/freeterminaleditor.cpp +++ b/sources/TerminalStrip/ui/freeterminaleditor.cpp @@ -52,7 +52,7 @@ FreeTerminalEditor::FreeTerminalEditor(QETProject *project, QWidget *parent) : connect(ui->m_table_view->selectionModel(), &QItemSelectionModel::selectionChanged, this, &FreeTerminalEditor::selectionChanged); - connect(ui->m_table_view, &QAbstractItemView::doubleClicked, this, [=](const QModelIndex &index) + connect(ui->m_table_view, &QAbstractItemView::doubleClicked, this, [this](const QModelIndex &index) { if (m_model->columnTypeForIndex(index) == FreeTerminalModel::XRef) { diff --git a/sources/TerminalStrip/ui/terminalstripeditor.cpp b/sources/TerminalStrip/ui/terminalstripeditor.cpp index 364aa8acc..d0adf173e 100644 --- a/sources/TerminalStrip/ui/terminalstripeditor.cpp +++ b/sources/TerminalStrip/ui/terminalstripeditor.cpp @@ -53,7 +53,7 @@ TerminalStripEditor::TerminalStripEditor(QETProject *project, QWidget *parent) : selectionChanged(); //Go the diagram of double clicked terminal - connect(ui->m_table_widget, &QAbstractItemView::doubleClicked, this, [=](const QModelIndex &index) + connect(ui->m_table_widget, &QAbstractItemView::doubleClicked, this, [this](const QModelIndex &index) { if (m_model->columnTypeForIndex(index) == TerminalStripModel::XRef) { diff --git a/sources/TerminalStrip/ui/terminalstripmodel.cpp b/sources/TerminalStrip/ui/terminalstripmodel.cpp index db7f39d54..116803636 100644 --- a/sources/TerminalStrip/ui/terminalstripmodel.cpp +++ b/sources/TerminalStrip/ui/terminalstripmodel.cpp @@ -110,7 +110,7 @@ TerminalStripModel::TerminalStripModel(TerminalStrip *terminal_strip, QObject *p { fillPhysicalTerminalData(); - connect(terminal_strip, &TerminalStrip::bridgeColorChanged, this, [=] { + connect(terminal_strip, &TerminalStrip::bridgeColorChanged, this, [this] { emit dataChanged(index(0, LEVEL_0_CELL), index(rowCount(), LEVEL_3_CELL)); }); diff --git a/sources/diagramview.cpp b/sources/diagramview.cpp index 8e49a1fe3..b4c5cf126 100644 --- a/sources/diagramview.cpp +++ b/sources/diagramview.cpp @@ -1752,7 +1752,7 @@ void DiagramView::setEventInterface(DVEventInterface *event_interface) { if (m_event_interface) delete m_event_interface; m_event_interface = event_interface; - connect(m_event_interface, &DVEventInterface::finish, this, [=](){delete this->m_event_interface; this->m_event_interface = nullptr;}, Qt::QueuedConnection); + connect(m_event_interface, &DVEventInterface::finish, this, [this](){delete this->m_event_interface; this->m_event_interface = nullptr;}, Qt::QueuedConnection); } /** diff --git a/sources/editor/ui/dynamictextfieldeditor.cpp b/sources/editor/ui/dynamictextfieldeditor.cpp index 7cd15dd62..3f444a8e3 100644 --- a/sources/editor/ui/dynamictextfieldeditor.cpp +++ b/sources/editor/ui/dynamictextfieldeditor.cpp @@ -199,18 +199,18 @@ void DynamicTextFieldEditor::setUpConnections() disconnectConnections(); //Setup the connection - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::colorChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::fontChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::taggChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textFromChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::infoNameChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::frameChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textWidthChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::compositeTextChanged,this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::keepVisualRotationChanged, this, [=](){this -> updateForm();}); - m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationPointCenterChanged, this, [=](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::colorChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::fontChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::taggChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textFromChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::infoNameChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::frameChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::textWidthChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::compositeTextChanged,this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::keepVisualRotationChanged, this, [this](){this -> updateForm();}); + m_connection_list << connect(m_text_field.data(), &PartDynamicTextField::rotationPointCenterChanged, this, [this](){this -> updateForm();}); // Refresh info combo when element data changes (e.g. type switched to PLC-Slave) m_connection_list << connect(elementEditor()->elementScene(), &ElementScene::elementInfoChanged, diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 80d52f4dd..08434ce16 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -612,7 +612,7 @@ void QETDiagramEditor::setUpActions() //Add a nomenclature item m_add_nomenclature = new QAction(QET::Icons::TableOfContent, tr("Ajouter une nomenclature"), this); - connect(m_add_nomenclature, &QAction::triggered, this, [=]() { + connect(m_add_nomenclature, &QAction::triggered, this, [this]() { if(this->currentDiagramView()) { QetGraphicsTableFactory::createAndAddNomenclature(this->currentDiagramView()->diagram()); } @@ -620,14 +620,14 @@ void QETDiagramEditor::setUpActions() //Add a summary item m_add_summary = new QAction(QET::Icons::TableOfContent, tr("Ajouter un sommaire"), this); - connect(m_add_summary, &QAction::triggered, this, [=]() { + connect(m_add_summary, &QAction::triggered, this, [this]() { if(this->currentDiagramView()) { QetGraphicsTableFactory::createAndAddSummary(this->currentDiagramView()->diagram()); } }); m_terminal_strip_dialog = new QAction(QET::Icons::TerminalStrip, tr("Gestionnaire de borniers (DEV)"), this); - connect(m_terminal_strip_dialog, &QAction::triggered, this, [=]() + connect(m_terminal_strip_dialog, &QAction::triggered, this, [this]() { if (auto project = this->currentProject()) { diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index b89acc626..aae5a8bae 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -346,7 +346,7 @@ void QETProject::init() { int ms = autosave_interval*60*1000; m_autosave_timer.setInterval(ms); - connect(&m_autosave_timer, &QTimer::timeout, this, [=]() + connect(&m_autosave_timer, &QTimer::timeout, this, [this]() { if(!this->m_file_path.isEmpty()) this->write(); diff --git a/sources/ui/linksingleelementwidget.cpp b/sources/ui/linksingleelementwidget.cpp index 322e955bf..67817dcfa 100644 --- a/sources/ui/linksingleelementwidget.cpp +++ b/sources/ui/linksingleelementwidget.cpp @@ -73,7 +73,7 @@ LinkSingleElementWidget::LinkSingleElementWidget(Element *elmt, connect(ui->m_hide_full_masters_cb, &QCheckBox::toggled, this, &LinkSingleElementWidget::hideFullMastersToggled); - connect(m_show_qtwi, &QAction::triggered, this, [=]() + connect(m_show_qtwi, &QAction::triggered, this, [this]() { this->on_m_tree_widget_itemDoubleClicked(this->m_qtwi_at_context_menu, 0); }); @@ -83,7 +83,7 @@ LinkSingleElementWidget::LinkSingleElementWidget(Element *elmt, this, &LinkSingleElementWidget::linkTriggered); - connect(m_show_element, &QAction::triggered, this, [=]() + connect(m_show_element, &QAction::triggered, this, [this]() { this->m_element->diagram()->showMe(); this->m_element->setHighlighted(true); From 2b813a0d91fbacdc0918fddd95f4a9a1ed77ed0a Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Mon, 28 Sep 2026 23:03:11 +0200 Subject: [PATCH 10/45] Fix Qt6 package name which was forgotten when removing the variable used to differentiate between Qt5 nd Qt6. --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1694a98c0..9ede16889 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,8 +93,8 @@ endif() # the application depends on. When it's missing the feature is silently # disabled - see the QET_HAS_SCRIPTING guard in qetscripting.*. set(QET_HAS_SCRIPTING FALSE) -find_package(Qt${QT_VERSION_MAJOR} QUIET COMPONENTS Qml) -if(TARGET Qt${QT_VERSION_MAJOR}::Qml) +find_package(Qt6 QUIET COMPONENTS Qml) +if(TARGET Qt6::Qml) set(QET_HAS_SCRIPTING TRUE) list(APPEND QET_PRIVATE_LIBRARIES Qt::Qml) add_compile_definitions(QET_HAS_SCRIPTING) From 2815dbaf47f508b437e5798b830ad342d98ef3af Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 09:37:57 +1300 Subject: [PATCH 11/45] Symbol editor: give old terminals their derived uuid, not a random one A symbol file whose terminals have no uuid got a random one for each terminal when opened in the element editor (PartTerminal's constructor), written on save. Every copy of the same old symbol therefore ended up with different terminal uuids, none of them the one a project gives the same terminals on opening (TerminalUuids::fillMissing()). ElementScene::loadContent() now reads a copy of the definition filled by TerminalUuids::fillMissingInDefinition(), the same rule as a project, including the next occurrence for the second of two terminals at one point. Terminals that have a uuid keep it; a paste still renews them all (PastePartsCommand). Checked in the editor: 6es7_212-1ae40-0xb0__p3.elmt (no terminal uuids in the collection) and tm3saf5r_layout.elmt with its uuids stripped (two terminals at one point), opened, nudged back and forth, saved: this build writes exactly the derived values (computed independently in Python), the previous one random ones. Select all, copy, paste, save: 6 terminals, 6 distinct uuids. Co-Authored-By: Claude Opus 5.5 --- sources/ElementsCollection/terminaluuids.cpp | 36 +++++++++++++++----- sources/ElementsCollection/terminaluuids.h | 1 + sources/editor/elementscene.cpp | 8 +++++ tests/qttest/tst_terminaluuids.cpp | 23 +++++++++++++ 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/sources/ElementsCollection/terminaluuids.cpp b/sources/ElementsCollection/terminaluuids.cpp index fe9141f47..c5b7ba098 100644 --- a/sources/ElementsCollection/terminaluuids.cpp +++ b/sources/ElementsCollection/terminaluuids.cpp @@ -28,12 +28,12 @@ namespace { @return the of the definition held by the embedded collection element @p collection_element */ -QList terminalsOf(const QDomElement &collection_element) + //The of the @p definition +QList definitionTerminals(const QDomElement &definition) { QList terminals; - const QDomElement description = collection_element - .firstChildElement(QStringLiteral("definition")) - .firstChildElement(QStringLiteral("description")); + const QDomElement description = + definition.firstChildElement(QStringLiteral("description")); for (QDomElement t = description.firstChildElement(QStringLiteral("terminal")); !t.isNull(); t = t.nextSiblingElement(QStringLiteral("terminal"))) { @@ -42,6 +42,12 @@ QList terminalsOf(const QDomElement &collection_element) return terminals; } +QList terminalsOf(const QDomElement &collection_element) +{ + return definitionTerminals( + collection_element.firstChildElement(QStringLiteral("definition"))); +} + //Place and orientation, "10" and "10.0" being the same place QString terminalPlace(const QDomElement &terminal) { @@ -61,10 +67,10 @@ int orientationOf(const QDomElement &terminal) return 0; } - //The of one element definition get a uuid where missing -int fillDefinition(const QDomElement &collection_element) + //The of one get a uuid where missing +int fillDefinition(const QDomElement &definition) { - const QList terminals = terminalsOf(collection_element); + const QList terminals = definitionTerminals(definition); QSet taken; for (const QDomElement &t : terminals) { const QUuid uuid(t.attribute(QStringLiteral("uuid"))); @@ -105,7 +111,8 @@ int fillDirectory(const QDomElement &directory) if (child.tagName() == QLatin1String("category")) { filled += fillDirectory(child); } else if (child.tagName() == QLatin1String("element")) { - filled += fillDefinition(child); + filled += fillDefinition( + child.firstChildElement(QStringLiteral("definition"))); } } return filled; @@ -252,3 +259,16 @@ int TerminalUuids::fillMissing(const QDomElement &collection_root) { return fillDirectory(collection_root); } + +/** + @brief TerminalUuids::fillMissingInDefinition + fillMissing() for a single element definition (@p definition is its + ), as the element editor reads it from a file: an old + symbol opened and saved there gets the uuids a project gives the same + terminals, not random ones that would make every copy of it differ. + @return the number of terminals given a uuid +*/ +int TerminalUuids::fillMissingInDefinition(const QDomElement &definition) +{ + return fillDefinition(definition); +} diff --git a/sources/ElementsCollection/terminaluuids.h b/sources/ElementsCollection/terminaluuids.h index d5c94f517..f9892b0a6 100644 --- a/sources/ElementsCollection/terminaluuids.h +++ b/sources/ElementsCollection/terminaluuids.h @@ -26,6 +26,7 @@ namespace TerminalUuids { QUuid derived(qreal x, qreal y, int orientation, int occurrence = 0); int fillMissing(const QDomElement &collection_root); + int fillMissingInDefinition(const QDomElement &definition); void keep(const QDomElement &old_element, QDomElement &new_element); void keepInDirectory(const QDomElement &old_directory, QDomElement &new_directory); diff --git a/sources/editor/elementscene.cpp b/sources/editor/elementscene.cpp index 534d435c9..c0105ae83 100644 --- a/sources/editor/elementscene.cpp +++ b/sources/editor/elementscene.cpp @@ -17,6 +17,7 @@ */ #include "elementscene.h" +#include "../ElementsCollection/terminaluuids.h" #include "../NameList/ui/namelistdialog.h" #include "../NameList/ui/namelistwidget.h" #include "../QPropertyUndoCommand/qpropertyundocommand.h" @@ -1196,6 +1197,13 @@ ElementContent ElementScene::loadContent(const QDomDocument &xml_document) if (root.tagName() != "definition" || root.attribute("type") != "element") return(loaded_parts); + //Terminals saved without a uuid get the one a project gives them + //(see TerminalUuids::fillMissing()), not a random one: the same old + //symbol then has the same terminal uuids wherever it is copied. + //On a copy, the document is the caller's. A paste renews them all. + root = root.cloneNode(true).toElement(); + TerminalUuids::fillMissingInDefinition(root); + //Load the graphic description of the element for (QDomNode node = root.firstChild() ; !node.isNull() ; node = node.nextSibling()) { diff --git a/tests/qttest/tst_terminaluuids.cpp b/tests/qttest/tst_terminaluuids.cpp index b9a7fe1fd..35caba47f 100644 --- a/tests/qttest/tst_terminaluuids.cpp +++ b/tests/qttest/tst_terminaluuids.cpp @@ -300,6 +300,29 @@ private slots: QCOMPARE(TerminalUuids::fillMissing(root), 0); } + // The element editor fills a definition read from a file with exactly + // what a project fills the same definition with. + void fillMissingInDefinitionMatchesProject() + { + QDomDocument doc; + const QString own = QStringLiteral("{0f5d4b0c-2f7e-4a55-9a51-8c3a3e1c2d11}"); + QDomElement in_project = symbol(doc, {{"0", "10", "s"}, + {"0", "10", "s"}, + {"5", "0", "e", own}, + {"-3", "0", "w"}}); + QDomElement root = doc.createElement(QStringLiteral("collection")); + root.appendChild(in_project); + QDomElement definition = in_project.firstChildElement(QStringLiteral("definition")) + .cloneNode(true).toElement(); + + QCOMPARE(TerminalUuids::fillMissingInDefinition(definition), 3); + QCOMPARE(TerminalUuids::fillMissing(root), 3); + QCOMPARE(uuids(definition), uuids(in_project)); + QCOMPARE(uuids(definition).at(2), own); + QCOMPARE(uuids(definition).at(1), TerminalUuids::derived(0, 10, 2, 1).toString()); + QCOMPARE(TerminalUuids::fillMissingInDefinition(definition), 0); + } + // An example whose symbols have no terminal uuids and whose wires are // all in the numbered form: once saved, every terminal has a uuid, // every wire names its ends by uuid, nothing is lost, and saving again From 22f1455868f0cc88df4317ac703a429a85525466 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 09:55:27 +1300 Subject: [PATCH 12/45] Symbol editor: imported elements get new terminal uuids Review of the previous commit: "Import scaled element" and "Import DXF" add a definition to an open symbol through OpenElmtCommand, which, unlike a paste, kept the imported terminals' uuids. With derived uuids, an old symbol imported into itself (or into another with a terminal at the same point) gave two terminals one uuid; importing any symbol whose terminals already carried uuids did the same before this series. OpenElmtCommand now renews the imported terminals' uuids, as PastePartsCommand does. Checked in the editor with a stand-in scaler at scale 1: open 6es7_212-1ae40-0xb0__p3.elmt, import it into itself, save -- 6 terminals, 3 distinct uuids without this commit, 6 with it, the original 3 keeping their derived values. Co-Authored-By: Claude Opus 5.5 --- sources/editor/UndoCommand/openelmtcommand.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/sources/editor/UndoCommand/openelmtcommand.cpp b/sources/editor/UndoCommand/openelmtcommand.cpp index f94ab4c2f..ff28b4adc 100644 --- a/sources/editor/UndoCommand/openelmtcommand.cpp +++ b/sources/editor/UndoCommand/openelmtcommand.cpp @@ -17,6 +17,7 @@ */ #include "openelmtcommand.h" #include "../elementscene.h" +#include "../graphicspart/partterminal.h" #include #include @@ -52,6 +53,15 @@ void OpenElmtCommand::redo() if (m_first_redo) { m_scene->fromXml(m_document, QPointF(), true, &m_graphics_item); + //The terminals are added to an element that may already hold + //the same ones -- importing the same symbol twice, or two old + //symbols whose terminals get the same derived uuid -- so they + //get new uuids, as a paste gives them (PastePartsCommand). + for (QGraphicsItem *qgi : std::as_const(m_graphics_item)) { + if (qgi->type() == PartTerminal::Type) { + static_cast(qgi)->setNewUuid(); + } + } m_scene->qgiManager().manage(m_graphics_item); m_first_redo = false; From a72384850a042ca6aab2057640458225598c18ca Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Mon, 28 Sep 2026 23:19:46 +0200 Subject: [PATCH 13/45] Replace depreciated aAdConst with STL. --- sources/shortcutmanager.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/shortcutmanager.cpp b/sources/shortcutmanager.cpp index 56d43b228..0ea6f2acd 100644 --- a/sources/shortcutmanager.cpp +++ b/sources/shortcutmanager.cpp @@ -181,7 +181,7 @@ bool ShortcutManager::trigger(const QString &id) const return false; } - for (const QPointer &target : qAsConst(it->targets)) + for (const QPointer &target : std::as_const(it->targets)) { if (!target) { continue; @@ -213,7 +213,7 @@ QAction *ShortcutManager::action(const QString &id, const QObject *owner) const return nullptr; } - for (const QPointer &target : qAsConst(it->targets)) + for (const QPointer &target : std::as_const(it->targets)) { auto *action = qobject_cast(target.data()); if (!action) { From b411a0834038d3e648b393d0bf3a4a975ad33164 Mon Sep 17 00:00:00 2001 From: Laurent Trinques Date: Mon, 28 Sep 2026 23:34:05 +0200 Subject: [PATCH 14/45] git submodule update --remote elements --- elements | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elements b/elements index 08e438a91..b50bdd8d1 160000 --- a/elements +++ b/elements @@ -1 +1 @@ -Subproject commit 08e438a919eb42ec191138d4178a5c0957d7474c +Subproject commit b50bdd8d1b3986370b0b76807e65a4ee2efaa2b4 From 91ef85f891ac199da74f7c39a5446e9d281b2c73 Mon Sep 17 00:00:00 2001 From: Andre Rummler Date: Mon, 28 Sep 2026 23:38:01 +0200 Subject: [PATCH 15/45] Unify Qt6:: usage. --- CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ede16889..a14f309f1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,7 +80,7 @@ set(QET_HAS_QTPDF FALSE) find_package(Qt6 QUIET COMPONENTS Pdf) if(TARGET Qt6::Pdf AND NOT Qt6_VERSION VERSION_LESS 6.4.0) set(QET_HAS_QTPDF TRUE) - list(APPEND QET_PRIVATE_LIBRARIES Qt::Pdf) + list(APPEND QET_PRIVATE_LIBRARIES Qt6::Pdf) add_compile_definitions(QET_HAS_QTPDF) else() message(STATUS "QtPdf module not available (or Qt < 6.4): PDF page import feature disabled") @@ -96,7 +96,7 @@ set(QET_HAS_SCRIPTING FALSE) find_package(Qt6 QUIET COMPONENTS Qml) if(TARGET Qt6::Qml) set(QET_HAS_SCRIPTING TRUE) - list(APPEND QET_PRIVATE_LIBRARIES Qt::Qml) + list(APPEND QET_PRIVATE_LIBRARIES Qt6::Qml) add_compile_definitions(QET_HAS_SCRIPTING) else() message(STATUS "Qt Qml module not available: JavaScript scripting (--run) disabled") From 9fdd6ac3332891ff12d596233a4d319377558bf8 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 10:40:11 +1300 Subject: [PATCH 16/45] Scripting: find a terminal by its uuid The conductor calls name a wire end as element uuid + terminal index. The index is the terminal's place in Element::terminals(), a sort by position that is undefined between two terminals at the same point, and the documentation ruled terminal uuids out as "empty for most of the installed base". Since #1118 every terminal of an opened project has one. - elementTerminals() ends each line with the terminal's uuid (Terminal::stableUuid()); the text before it is unchanged. - terminalIndex(folio, elementUuid, terminalUuid) returns the index the calls take, or -1 if the element or terminal is not there, or if two of the element's terminals carry that uuid. - The class documentation says what does address a terminal: its uuid together with its element's. tst_scriptterminaluuid runs --run on perceuse.qet (552 elements, two terminals at one point in some): every terminal listed with a uuid, distinct within its element, found again at its own index; -1 for an unknown or malformed uuid, an unknown element and a bad folio. Red when terminalIndex() returns the wrong index. qet-mcp suite 253/253. Co-Authored-By: Claude Opus 5.5 --- sources/scripting/qetscriptapi.cpp | 53 ++++++++--- sources/scripting/qetscriptapi.h | 20 ++-- tests/qttest/CMakeLists.txt | 13 +++ tests/qttest/tst_scriptterminaluuid.cpp | 121 ++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 tests/qttest/tst_scriptterminaluuid.cpp diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 8cbbd926a..c7344678a 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -739,18 +739,19 @@ QString QetScriptApi::elementName(int folioIndex, const QString &elementUuid) co /** @brief QetScriptApi::elementTerminals The element's terminals, in the order addConductor() indexes them: one - entry per terminal, ": ( conductor(s))". Descriptive - rather than structured because its only job is to let a script -- or a - human reading a script's output -- see which index is which before - wiring anything to it. + entry per terminal, ": ( conductor(s)) ". + Descriptive rather than structured because its only job is to let a + script -- or a human reading a script's output -- see which index is + which before wiring anything to it. - Indexes, not uuids, because a terminal uuid does not address a terminal - on a folio. Terminal::uuid() comes from the catalog .elmt definition - (see Terminal::stableUuid()), so it is empty for most of the installed - base, and where it is not, every instance of that same element carries - the same one -- two coils of one type placed side by side have - byte-identical terminal uuids, which is plainly visible in the saved - file of any project written through this API. + The calls take the index; the uuid is what to hold instead, and + terminalIndex() turns it back into the index. It is Terminal:: + stableUuid(): the terminal's own uuid, which every terminal of an + opened project has (TerminalUuids::fillMissing()), or for a symbol + imported since the project was opened the value the next opening will + give it. It comes from the symbol's definition, so it names a terminal + only together with its element: two coils of one type placed side by + side have the same terminal uuids. The index is the terminal's place in Element::terminals(), and that is @b not the order the .elmt lists them in. Element::parseTerminal() @@ -772,14 +773,40 @@ QStringList QetScriptApi::elementTerminals(int folioIndex, const QString &elemen for (int i = 0 ; i < terminals.count() ; ++i) { Terminal *t = terminals.at(i); - list << QStringLiteral("%1: %2 (%3 conductor(s))") + list << QStringLiteral("%1: %2 (%3 conductor(s)) %4") .arg(i) .arg(t->name().isEmpty() ? QStringLiteral("-") : t->name()) - .arg(t->conductorsCount()); + .arg(t->conductorsCount()) + .arg(t->stableUuid().toString()); } return list; } +/** + @brief QetScriptApi::terminalIndex + The current index in elementTerminals() of the terminal of the element + @p elementUuid whose uuid is @p terminalUuid, or -1 -- also when two of + its terminals carry that uuid, since the index would then be a guess. + The index is what addConductor() and the conductor calls take; unlike + the uuid, it is undefined between two terminals at the same point. +*/ +int QetScriptApi::terminalIndex(int folioIndex, const QString &elementUuid, + const QString &terminalUuid) const +{ + Element *element = findElement(folioIndex, elementUuid); + const QUuid wanted(terminalUuid); + if (!element || wanted.isNull()) return -1; + + int found = -1; + const QList terminals = element->terminals(); + for (int i = 0 ; i < terminals.count() ; ++i) { + if (terminals.at(i)->stableUuid() != wanted) continue; + if (found >= 0) return -1; + found = i; + } + return found; +} + QString QetScriptApi::elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const { Element *element = findElement(folioIndex, elementUuid); diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 62240935e..dff3225e7 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -96,14 +96,16 @@ class QetGraphicsTableItem; no way to connect them. Terminals are addressed by their @b index in Element::terminals(), - not by uuid, and elementTerminals() prints that indexing so a script - can see what it is about to wire. The index is the terminal's place - in the element's own top-to-bottom, left-to-right ordering, not the - order its definition file lists them. Terminal uuids look like the - obvious key and are not one: Terminal::uuid() is a property of the - catalog .elmt definition, empty for most of the installed base and, - where present, identical across every instance of that element -- so - it does not distinguish one placed coil's A1 from another's. + and elementTerminals() prints that indexing so a script can see what + it is about to wire. The index is the terminal's place in the + element's own top-to-bottom, left-to-right ordering, not the order + its definition file lists them, and it is undefined between two + terminals at the same point. What does not move is the terminal's + uuid together with its element's: each elementTerminals() line ends + with the terminal's uuid, and terminalIndex() turns element uuid + + terminal uuid back into the index. A terminal uuid alone is not an + address -- it comes from the symbol's definition, so every placed + instance of one symbol has the same ones. - @b Conductor properties and @b cross-references: set a conductor's number, formula, colour or section (and its look: style normal/ dashed/dashdotted, two-colour mode and second colour, dash size, @@ -384,6 +386,8 @@ class QetScriptApi : public QObject Q_INVOKABLE QStringList elementUuids(int folioIndex) const; Q_INVOKABLE QString elementName(int folioIndex, const QString &elementUuid) const; Q_INVOKABLE QStringList elementTerminals(int folioIndex, const QString &elementUuid) const; + Q_INVOKABLE int terminalIndex(int folioIndex, const QString &elementUuid, + const QString &terminalUuid) const; // -- element information, through ChangeElementInformationCommand -- Q_INVOKABLE QString elementInfo(int folioIndex, const QString &elementUuid, const QString &key) const; diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 9656c557f..f3726de04 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -343,6 +343,19 @@ if(QET_HAS_SCRIPTING) target_link_libraries(tst_scriptconductoruuid PRIVATE Qt::Test) target_compile_definitions(tst_scriptconductoruuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + + # qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex() + # turns element uuid + terminal uuid back into the index the conductor + # calls take, on examples/perceuse.qet (two terminals at one point). + add_executable( + tst_scriptterminaluuid + tst_scriptterminaluuid.cpp) + add_test(NAME tst_scriptterminaluuid COMMAND tst_scriptterminaluuid) + add_dependencies(tst_scriptterminaluuid qelectrotech) + target_link_libraries(tst_scriptterminaluuid PRIVATE Qt::Test) + target_compile_definitions(tst_scriptterminaluuid PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") endif() # A symbol saved without a uuid gets the same one on every load, and diff --git a/tests/qttest/tst_scriptterminaluuid.cpp b/tests/qttest/tst_scriptterminaluuid.cpp new file mode 100644 index 000000000..ff8bece9c --- /dev/null +++ b/tests/qttest/tst_scriptterminaluuid.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// qet.elementTerminals() ends each line with the terminal's uuid, and +// qet.terminalIndex(folio, element, terminalUuid) turns it back into the +// index the conductor calls take. Runs a script through the real binary's +// --run on perceuse.qet, which has symbols with two terminals at one point: +// their order in the index is undefined, their uuids are not. +class tst_scriptterminaluuid : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // Run @p script on @p project in a sandbox of its own and return the + // JSON object it logged. + QJsonObject run(const QString &script, const QString &project) + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + if (!f.open(QIODevice::WriteOnly)) return {}; + f.write(script.toUtf8()); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, project}); + if (!proc.waitForFinished(120000)) return {}; + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + const QString mark = QStringLiteral("PROBE "); + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(mark); + if (i >= 0) + return QJsonDocument::fromJson(line.mid(i + mark.size()).toUtf8()).object(); + } + return {}; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void everyTerminalFoundByItsUuid() + { + const QJsonObject r = run(QStringLiteral( + "var rows = [], pick = null;\n" + "for (var f = 0; f < qet.folioCount(); f++) {\n" + " var els = qet.elementUuids(f);\n" + " for (var e = 0; e < els.length; e++) {\n" + " var lines = qet.elementTerminals(f, els[e]);\n" + " var back = lines.map(function (l) {\n" + " return qet.terminalIndex(f, els[e], l.substring(l.lastIndexOf(' ') + 1)); });\n" + " rows.push({lines: lines, back: back});\n" + " if (!pick && lines.length) pick = {f: f, e: els[e], t: lines[0].split(' ').pop()};\n" + " }\n" + "}\n" + "qet.log('PROBE ' + JSON.stringify({rows: rows,\n" + " unknown: qet.terminalIndex(pick.f, pick.e, '{00000000-0000-0000-0000-000000000001}'),\n" + " junk: qet.terminalIndex(pick.f, pick.e, 'not a uuid'),\n" + " noElement: qet.terminalIndex(pick.f, '{00000000-0000-0000-0000-000000000002}', pick.t),\n" + " badFolio: qet.terminalIndex(99, pick.e, pick.t)}));\n"), + QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + QVERIFY2(!r.isEmpty(), "the script logged nothing"); + + const QRegularExpression line_re(QStringLiteral( + "^(\\d+): .* \\(\\d+ conductor\\(s\\)\\) (\\{[0-9a-f-]{36}\\})$")); + int terminals = 0; + for (const QJsonValue &row : r.value(QStringLiteral("rows")).toArray()) { + const QJsonArray lines = row.toObject().value(QStringLiteral("lines")).toArray(); + const QJsonArray back = row.toObject().value(QStringLiteral("back")).toArray(); + QCOMPARE(back.size(), lines.size()); + QSet uuids; + for (int i = 0; i < lines.size(); ++i) { + const QString line = lines.at(i).toString(); + const QRegularExpressionMatch m = line_re.match(line); + QVERIFY2(m.hasMatch(), qPrintable(line)); + QCOMPARE(m.captured(1).toInt(), i); + QVERIFY(!QUuid(m.captured(2)).isNull()); + uuids.insert(m.captured(2)); + // the uuid finds this very terminal again + QCOMPARE(back.at(i).toInt(), i); + ++terminals; + } + QCOMPARE(uuids.size(), lines.size()); + } + QVERIFY(terminals > 200); + + QCOMPARE(r.value(QStringLiteral("unknown")).toInt(), -1); + QCOMPARE(r.value(QStringLiteral("junk")).toInt(), -1); + QCOMPARE(r.value(QStringLiteral("noElement")).toInt(), -1); + QCOMPARE(r.value(QStringLiteral("badFolio")).toInt(), -1); + } +}; + +QTEST_APPLESS_MAIN(tst_scriptterminaluuid) + +#include "tst_scriptterminaluuid.moc" From f58bf7502ece45fecdb7dda8f96f6982cbc82e1f Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 10:54:03 +1300 Subject: [PATCH 17/45] qet-mcp: qet_edit names a terminal by its uuid "terminal", "from_terminal" and "to_terminal" take the terminal's uuid (as qet_element_info lists it) in place of its index. It names a terminal of the op's own element -- for add_conductor, of that end's element, "$name" references included -- and is turned at run time into the index the call takes by qet.terminalIndex(); if the element has no such terminal the op fails with a note. Unlike the index, a sort by position, it is defined between two terminals at the same point. The lookup is required only when a uuid is used, so index-only edits still run on older builds. README: the terminal uuids; wires of older projects now get a lasting uuid (#1107) instead of staying unnamed. Tests: script generation for every terminal-taking op, "$name" and folio uuid resolution, the index still passed through; through the binary, bobine_ka_a_remanence (file order A2, A1; index order A1, A2) wired A2 to A1 by uuid lands on exactly those ends, and an unknown uuid stops the run with its note. 255/255 against a build with qet.terminalIndex(); a build without it is reported as missing it. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 16 +++++++-- misc/qet-mcp/qet_mcp.py | 45 +++++++++++++++++++---- misc/qet-mcp/test_qet_mcp.py | 70 ++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 10 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index c8e994d9b..c09f84bd4 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -305,9 +305,19 @@ Python, plus the hang guard on `addConductor` and the database refresh in `qet.conductorEnds()` in the build. A uuid names one wire, but `set_conductor` still changes the whole potential, the same as by terminal. A project saved before conductors carried a uuid has none in - the file: QElectroTech makes a new one on every load and does not save - it, so `qet_conductors` reports `uuid` as empty and those conductors are - named by `element` + `terminal` until wire uuids last (#1103). + the file until it is saved once: since #1107 QElectroTech works one out + from the wire's two ends on load and writes it on the next save, so it + appears after a first `qet_edit`. +- **A terminal can be named by its uuid**: `terminal`, `from_terminal` and + `to_terminal` take the terminal's uuid (as `qet_element_info` lists it) + in place of its index, on the op's own element (for `add_conductor`, on + that end's element). It is turned at run time into the index the call + takes; if the element has no terminal with it the op fails and its + `note` says so. Unlike the index, which is a sort by position, it is + defined between two terminals at the same point. Needs + `qet.terminalIndex()` in the build. A symbol file saved without terminal + uuids lists them empty; QElectroTech gives the terminals of every + project's copy of it a uuid on opening (#1118), written on the next save. - **`qet_export` isolates its launch.** SingleApplication keys its socket on `applicationFilePath()`, so a second launch of the same binary path forwards its request to an already-running instance and returns *that* diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 09d84c384..35cedb1d5 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1292,14 +1292,14 @@ OPS = { "set_info": ("setElementInfo", [("folio", "folio"), ("element", "elmt"), ("key", "str"), ("value", "str")]), "add_conductor": ("addConductor", [("folio", "folio"), - ("from", "elmt"), ("from_terminal", "num"), - ("to", "elmt"), ("to_terminal", "num")]), + ("from", "elmt"), ("from_terminal", "term"), + ("to", "elmt"), ("to_terminal", "term")]), "delete_element": ("deleteElement", [("folio", "folio"), ("element", "elmt")]), "set_conductor": ("setConductorProperty", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num"), ("property", "str"), + ("terminal", "term"), ("property", "str"), ("value", "str")]), "move_conductor_segment": ("moveConductorSegment", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num"), ("segment", "num"), + ("terminal", "term"), ("segment", "num"), ("dx", "num"), ("dy", "num")]), "link_elements": ("linkElements", [("folio", "folio"), ("element", "elmt"), ("to_folio", "folio"), ("to", "elmt")]), @@ -1320,7 +1320,7 @@ OPS = { # uuid, which does not shift when another one is added. So # it can be named as "$id". Indexes shift when one is added or deleted. "delete_conductor": ("deleteConductor", [("folio", "folio"), ("element", "elmt"), - ("terminal", "num")]), + ("terminal", "term")]), "remove_folio": ("removeFolio", [("folio", "folio")]), "set_folio": ("setFolioProperty", [("folio", "folio"), ("property", "str"), ("value", "str")]), @@ -1469,6 +1469,7 @@ def _build_script(operations: list, output: str) -> str: uuid_methods: set[str] = set() folio_js = "0" element_js = None # the op's element, for lookups scoped to it + owner_js = None # the element a terminal argument belongs to lines = [ "// generated by qet-mcp; do not edit", "var R = {};", # $name -> value from an earlier op @@ -1509,6 +1510,16 @@ def _build_script(operations: list, output: str) -> str: "{kind: 'op_note', index: index, note: why}));", " return null;", "}", + # A terminal named by uuid is turned into the index the calls take, + # on its own element; -1, with the reason logged, if that element + # has no terminal with it. + "function qetMcpTerminal(index, folio, element, uuid) {", + " var t = qet.terminalIndex(folio, element, uuid);", + " if (t < 0) qet.log(" + _js(_MARKER) + " + JSON.stringify({kind: 'op_note', " + "index: index, note: 'no terminal ' + uuid + ' on element ' + element + " + "' (or two of its terminals carry it)'}));", + " return t;", + "}", "if (missing.length === 0) {", ] @@ -1561,6 +1572,20 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {op_index}: {key!r} must be a table index " f"or its uuid, got {value!r}") return _js(value) + if kind == "term": + # A terminal's uuid comes from its symbol's definition, so it + # names a terminal only on its element: the op's element, or for + # add_conductor the end's own. Turned into the index the call + # takes at run time; unlike the index, it is defined between two + # terminals at the same point. + if isinstance(value, str) and _UUID_RE.fullmatch(value): + uuid_methods.add("terminalIndex") + return (f"qetMcpTerminal({op_index}, {folio_js}, {owner_js}, " + f"{_js(value)})") + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a terminal index " + f"or its uuid, got {value!r}") + return _js(value) if kind == "element_text": # A field's uuid is unique only within its element (copies keep # them), so the lookup takes the op's element too. @@ -1700,6 +1725,7 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {i} ({name}) is missing {key!r}") folio_js = args[0] if args else "0" element_js = args[1] if len(args) > 1 else None + owner_js = args[-1] if args else None # a terminal's element precedes it args.append(ref_or(op[key], kind, i, key)) if conductor_js is not None: args[1], args[2] = f"e{i}.element", f"e{i}.terminal" @@ -2635,8 +2661,13 @@ TOOLS = [ "qet_project_info call 1 is \"folio\": 0 here. A folio can " "be given as its uuid instead (qet_project_info lists them), " "which still names the same folio after an earlier op adds or " - "removes one. Terminals are numbered by their " - "index in the element definition; qet_element_info lists them. " + "removes one. A terminal (\"terminal\", \"from_terminal\", " + "\"to_terminal\") is given by its index -- qet_element_info " + "lists terminals in index order, top to bottom then left to " + "right -- or by its uuid, which qet_element_info also lists and " + "which, unlike the index, tells apart two terminals at the same " + "point; a terminal uuid names a terminal of the op's own " + "element (for add_conductor, of that end's element). " "set_conductor addresses a conductor as the one on a given " "terminal and applies the change to its whole electrical " "potential, so name a terminal carrying exactly one conductor, " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index fdac11f55..c8cda96ff 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -242,6 +242,43 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "saved before conductors carried a uuid"): self.build([{"op": "delete_conductor", "folio": 0, "conductor": ""}]) + def test_terminal_by_uuid(self): + """A terminal named by uuid is turned into its index at run time, on + the element it belongs to: the op's element, or each add_conductor + end's own. The lookup is required only then; a plain index is + passed through as before.""" + E, F = "{11111111-2222-4333-8444-555555555555}", "{21111111-2222-4333-8444-555555555555}" + T, V = "{31111111-2222-4333-8444-555555555555}", "{41111111-2222-4333-8444-555555555555}" + s = self.build([{"op": "add_conductor", "folio": 1, "from": E, "from_terminal": T, + "to": F, "to_terminal": V}]) + self.assertIn(f'qet.addConductor(1, "{E}", qetMcpTerminal(0, 1, "{E}", "{T}"), ' + f'"{F}", qetMcpTerminal(0, 1, "{F}", "{V}"))', s) + self.assertIn('"terminalIndex"', s) + s = self.build([{"op": "set_conductor", "folio": 0, "element": E, "terminal": T, + "property": "num", "value": "W1"}]) + self.assertIn(f'qet.setConductorProperty(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), ' + '"num", "W1")', s) + s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": T}]) + self.assertIn(f'qet.deleteConductor(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"))', s) + s = self.build([{"op": "move_conductor_segment", "folio": 0, "element": E, + "terminal": T, "segment": 1, "dx": 5, "dy": 0}]) + self.assertIn(f'qet.moveConductorSegment(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), 1, 5, 0)', s) + # a $name element and a folio uuid reach the lookup resolved + s = self.build([{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "a", "folio": "$f", "path": "x.elmt", "x": 0, "y": 0}, + {"op": "delete_conductor", "folio": "$f", "element": "$a", "terminal": T}]) + self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, R["f"], R["a"], "{T}"))', s) + s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}]) + self.assertIn(f'qetMcpTerminal(0, qet.folioIndex("{V}"), "{E}", "{T}")', s) + # an index is unchanged and needs no lookup + s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": 2}]) + self.assertIn(f'qet.deleteConductor(0, "{E}", 2)', s) + self.assertNotIn('"terminalIndex"', s) + with self.assertRaisesRegex(ValueError, "terminal index or its uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": "A1"}]) + with self.assertRaisesRegex(ValueError, "terminal index or its uuid"): + self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": True}]) + def test_every_op_generates_a_script(self): # one minimal valid instance of every op f = {"op": "add_folio", "id": "f"} @@ -2235,6 +2272,39 @@ class Integration(unittest.TestCase): nums = [c["num"] for c in m.tool_conductors(r["output"])["conductors"]] self.assertEqual(nums, ["W1"]) + def test_add_conductor_by_terminal_uuid(self): + """The coil's file lists A2 before A1, and its index order puts A1 + first: wiring by uuid reaches the terminals named, whatever their + index, and an unknown uuid stops the run with a note.""" + info = m.tool_element_info(str(Path(ELEMENTS) / COIL.removeprefix("common://"))) + by_name = {t["name"]: t for t in info["terminals"]} + a1, a2 = by_name["A1"], by_name["A2"] + self.assertTrue(a1["uuid"] and a2["uuid"]) + self.assertEqual((a1["index"], a2["index"]), (0, 1)) + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_element", "id": "b", "folio": 0, "path": COIL, "x": 300, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", "from_terminal": a2["uuid"], + "to": "$b", "to_terminal": a1["uuid"]}])) + self.assertTrue(all(o["succeeded"] for o in r["operations"])) + wires = [c for c in ET.parse(r["output"]).getroot().iter("conductor") + if c.get("terminal1") and c.get("element1")] + self.assertEqual(len(wires), 1) + ends = {(wires[0].get("element1"), wires[0].get("terminal1")), + (wires[0].get("element2"), wires[0].get("terminal2"))} + a_uuid, b_uuid = (o["result"] for o in r["operations"][:2]) + self.assertEqual(ends, {(a_uuid, a2["uuid"]), (b_uuid, a1["uuid"])}) + + r = self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", + "from_terminal": "{00000000-0000-4000-8000-000000000001}", + "to": "$a", "to_terminal": a1["uuid"]}]) + self.assertFalse(r["ok"]) + self.assertTrue(r["stopped_early"]) + self.assertIn("no terminal", json.dumps(r["operations"][1])) + def test_noop_edit_has_no_conductor_churn(self): """Re-saving renumbers the file's terminal ids; the diff must not report the untouched conductors as removed and re-added.""" From 3112727ee737363c612d52298872b4256e2d17a9 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 10:49:24 +1300 Subject: [PATCH 18/45] elementTerminals(): keep a "%3" or "%4" in a terminal's name as written Review of the previous commit: the line was built with chained arg() calls, so a "%3" in a terminal's name was replaced by the conductor count -- as before -- and now a "%4" by the uuid as well. One multi-argument arg() substitutes each placeholder of the pattern once. percentInNameKept renames a terminal of perceuse.qet "x%3y%4": listed as "x1y{uuid}" before, "x%3y%4" now. Co-Authored-By: Claude Opus 5.5 --- sources/scripting/qetscriptapi.cpp | 10 ++++--- tests/qttest/CMakeLists.txt | 2 +- tests/qttest/tst_scriptterminaluuid.cpp | 36 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index c7344678a..53f3a41aa 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -773,11 +773,13 @@ QStringList QetScriptApi::elementTerminals(int folioIndex, const QString &elemen for (int i = 0 ; i < terminals.count() ; ++i) { Terminal *t = terminals.at(i); + //One multi-argument arg(): chained ones would also replace a + //"%3" or "%4" inside the terminal's name list << QStringLiteral("%1: %2 (%3 conductor(s)) %4") - .arg(i) - .arg(t->name().isEmpty() ? QStringLiteral("-") : t->name()) - .arg(t->conductorsCount()) - .arg(t->stableUuid().toString()); + .arg(QString::number(i), + t->name().isEmpty() ? QStringLiteral("-") : t->name(), + QString::number(t->conductorsCount()), + t->stableUuid().toString()); } return list; } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index f3726de04..e10e2ad16 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -352,7 +352,7 @@ if(QET_HAS_SCRIPTING) tst_scriptterminaluuid.cpp) add_test(NAME tst_scriptterminaluuid COMMAND tst_scriptterminaluuid) add_dependencies(tst_scriptterminaluuid qelectrotech) - target_link_libraries(tst_scriptterminaluuid PRIVATE Qt::Test) + target_link_libraries(tst_scriptterminaluuid PRIVATE Qt::Test Qt::Xml) target_compile_definitions(tst_scriptterminaluuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") diff --git a/tests/qttest/tst_scriptterminaluuid.cpp b/tests/qttest/tst_scriptterminaluuid.cpp index ff8bece9c..316282493 100644 --- a/tests/qttest/tst_scriptterminaluuid.cpp +++ b/tests/qttest/tst_scriptterminaluuid.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -114,6 +115,41 @@ private slots: QCOMPARE(r.value(QStringLiteral("noElement")).toInt(), -1); QCOMPARE(r.value(QStringLiteral("badFolio")).toInt(), -1); } + + // A "%3" or "%4" in a terminal's name is listed as written, not + // replaced by the conductor count or the uuid. + void percentInNameKept() + { + QFile in(QStringLiteral(QET_EXAMPLES_DIR "/perceuse.qet")); + QVERIFY(in.open(QIODevice::ReadOnly)); + QDomDocument doc; + QVERIFY(bool(doc.setContent(&in))); + const QDomNodeList terminals = doc.documentElement() + .firstChildElement(QStringLiteral("collection")) + .elementsByTagName(QStringLiteral("terminal")); + QVERIFY(!terminals.isEmpty()); + terminals.at(0).toElement().setAttribute(QStringLiteral("name"), + QStringLiteral("x%3y%4")); + const QString project = m_dir.filePath(QStringLiteral("percent.qet")); + QFile out(project); + QVERIFY(out.open(QIODevice::WriteOnly)); + out.write(doc.toByteArray()); + out.close(); + + const QJsonObject r = run(QStringLiteral( + "var hits = [];\n" + "for (var f = 0; f < qet.folioCount(); f++) {\n" + " var els = qet.elementUuids(f);\n" + " for (var e = 0; e < els.length; e++)\n" + " qet.elementTerminals(f, els[e]).forEach(function (l) {\n" + " if (/: x.*y.* \\(/.test(l)) hits.push(l); });\n" + "}\n" + "qet.log('PROBE ' + JSON.stringify({hits: hits}));\n"), project); + const QJsonArray hits = r.value(QStringLiteral("hits")).toArray(); + QVERIFY2(!hits.isEmpty(), "the renamed terminal was not listed"); + for (const QJsonValue &h : hits) + QVERIFY2(h.toString().contains(QStringLiteral(": x%3y%4 (")), qPrintable(h.toString())); + } }; QTEST_APPLESS_MAIN(tst_scriptterminaluuid) From ccc063a19eac80e8fe2ba16e76337a99470452fb Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 10:56:45 +1300 Subject: [PATCH 19/45] qet-mcp: report every wrong terminal uuid of an op, each by its argument Review of the previous commit: an add_conductor with both terminal uuids wrong logged two notes under one op index and the second replaced the first, and neither said which end it was about. Notes of one op are now joined, and a terminal note starts with its argument ("from_terminal:", "to_terminal:", "terminal:"). The integration test's both-ends case fails with the old replacing behaviour. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 11 +++++++---- misc/qet-mcp/test_qet_mcp.py | 26 ++++++++++++++++++-------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 35cedb1d5..68d064d53 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1513,10 +1513,10 @@ def _build_script(operations: list, output: str) -> str: # A terminal named by uuid is turned into the index the calls take, # on its own element; -1, with the reason logged, if that element # has no terminal with it. - "function qetMcpTerminal(index, folio, element, uuid) {", + "function qetMcpTerminal(index, key, folio, element, uuid) {", " var t = qet.terminalIndex(folio, element, uuid);", " if (t < 0) qet.log(" + _js(_MARKER) + " + JSON.stringify({kind: 'op_note', " - "index: index, note: 'no terminal ' + uuid + ' on element ' + element + " + "index: index, note: key + ': no terminal ' + uuid + ' on element ' + element + " "' (or two of its terminals carry it)'}));", " return t;", "}", @@ -1580,7 +1580,7 @@ def _build_script(operations: list, output: str) -> str: # terminals at the same point. if isinstance(value, str) and _UUID_RE.fullmatch(value): uuid_methods.add("terminalIndex") - return (f"qetMcpTerminal({op_index}, {folio_js}, {owner_js}, " + return (f"qetMcpTerminal({op_index}, {_js(key)}, {folio_js}, {owner_js}, " f"{_js(value)})") if not isinstance(value, int) or isinstance(value, bool): raise ValueError(f"operation {op_index}: {key!r} must be a terminal index " @@ -1808,7 +1808,10 @@ def _parse_script_output(text: str) -> dict: (isinstance(r, int) and not isinstance(r, bool) and r == -1)) ops.append(rec) elif rec.get("kind") == "op_note": - notes[rec.get("index")] = rec.get("note") + # An op can log more than one (add_conductor, one per end): + # keep them all rather than only the last. + idx = rec.get("index") + notes[idx] = (notes[idx] + "; " if idx in notes else "") + str(rec.get("note")) elif rec.get("kind") == "save": saved = bool(rec.get("result")) stopped = bool(rec.get("stopped_early")) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index c8cda96ff..cd814eb4d 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -251,25 +251,25 @@ class EditValidation(unittest.TestCase): T, V = "{31111111-2222-4333-8444-555555555555}", "{41111111-2222-4333-8444-555555555555}" s = self.build([{"op": "add_conductor", "folio": 1, "from": E, "from_terminal": T, "to": F, "to_terminal": V}]) - self.assertIn(f'qet.addConductor(1, "{E}", qetMcpTerminal(0, 1, "{E}", "{T}"), ' - f'"{F}", qetMcpTerminal(0, 1, "{F}", "{V}"))', s) + self.assertIn(f'qet.addConductor(1, "{E}", qetMcpTerminal(0, "from_terminal", 1, "{E}", "{T}"), ' + f'"{F}", qetMcpTerminal(0, "to_terminal", 1, "{F}", "{V}"))', s) self.assertIn('"terminalIndex"', s) s = self.build([{"op": "set_conductor", "folio": 0, "element": E, "terminal": T, "property": "num", "value": "W1"}]) - self.assertIn(f'qet.setConductorProperty(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), ' + self.assertIn(f'qet.setConductorProperty(0, "{E}", qetMcpTerminal(0, "terminal", 0, "{E}", "{T}"), ' '"num", "W1")', s) s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": T}]) - self.assertIn(f'qet.deleteConductor(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"))', s) + self.assertIn(f'qet.deleteConductor(0, "{E}", qetMcpTerminal(0, "terminal", 0, "{E}", "{T}"))', s) s = self.build([{"op": "move_conductor_segment", "folio": 0, "element": E, "terminal": T, "segment": 1, "dx": 5, "dy": 0}]) - self.assertIn(f'qet.moveConductorSegment(0, "{E}", qetMcpTerminal(0, 0, "{E}", "{T}"), 1, 5, 0)', s) + self.assertIn(f'qet.moveConductorSegment(0, "{E}", qetMcpTerminal(0, "terminal", 0, "{E}", "{T}"), 1, 5, 0)', s) # a $name element and a folio uuid reach the lookup resolved s = self.build([{"op": "add_folio", "id": "f"}, {"op": "add_element", "id": "a", "folio": "$f", "path": "x.elmt", "x": 0, "y": 0}, {"op": "delete_conductor", "folio": "$f", "element": "$a", "terminal": T}]) - self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, R["f"], R["a"], "{T}"))', s) + self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, "terminal", R["f"], R["a"], "{T}"))', s) s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}]) - self.assertIn(f'qetMcpTerminal(0, qet.folioIndex("{V}"), "{E}", "{T}")', s) + self.assertIn(f'qetMcpTerminal(0, "terminal", qet.folioIndex("{V}"), "{E}", "{T}")', s) # an index is unchanged and needs no lookup s = self.build([{"op": "delete_conductor", "folio": 0, "element": E, "terminal": 2}]) self.assertIn(f'qet.deleteConductor(0, "{E}", 2)', s) @@ -2303,7 +2303,17 @@ class Integration(unittest.TestCase): "to": "$a", "to_terminal": a1["uuid"]}]) self.assertFalse(r["ok"]) self.assertTrue(r["stopped_early"]) - self.assertIn("no terminal", json.dumps(r["operations"][1])) + self.assertIn("from_terminal: no terminal", json.dumps(r["operations"][1])) + + # both ends wrong: both reported, each by its argument + r = self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 0, "path": COIL, "x": 100, "y": 100}, + {"op": "add_conductor", "folio": 0, "from": "$a", + "from_terminal": "{00000000-0000-4000-8000-000000000001}", + "to": "$a", "to_terminal": "{00000000-0000-4000-8000-000000000002}"}]) + note = r["operations"][1].get("note", "") + self.assertIn("from_terminal: no terminal", note) + self.assertIn("to_terminal: no terminal", note) def test_noop_edit_has_no_conductor_churn(self): """Re-saving renumbers the file's terminal ids; the diff must not From 5683a535369f948a607c0fe37e1859f6362b5f48 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 11:00:58 +1300 Subject: [PATCH 20/45] English: fix broken and untranslated strings (#935, phases 1 and 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translation text only; no source string, no other language touched. "folio" is kept, as decided in #935, and the two "on the current sheet" strings whose source says folio now say folio too. - Broken wording: "N° scheme", "Export fully folio", "Title of folio", "Label folio", "N° of folio", "Dimensions of folio", "Folio Untitled", "Move up this folio", "Title block informations", the table "lines are missing %1" warning. - Folio report elements get one name, "folio reference", which DiagramView already used (was: folio referencing(s), reference folio following, previous reference folio, folio reports). - Machine-translation damage: "" broke the folio-reference error message's HTML, and two variable help texts listed "% F", "% l" etc., which are not variables. - Plurals still showing French or wrong: conductor colour undo entry (empty, so French was shown), "%n erreur", "%n forme", "%n item placed" for both forms, "redesigned" for redrawn, and "bornes" rendered as "boundaries" in the element-reload message. - Cross-reference spelled one way; "Réf." and "Numéro : %1" translated. Co-Authored-By: Claude Opus 5.5 --- lang/qet_en.ts | 138 ++++++++++++++++++++++++------------------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/lang/qet_en.ts b/lang/qet_en.ts index 5b308acbb..e76b18c19 100644 --- a/lang/qet_en.ts +++ b/lang/qet_en.ts @@ -865,7 +865,7 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Dimensions du folio - Dimensions of folio + Folio size @@ -996,8 +996,8 @@ Note: these options DO NOT allow or block auto numberings, only their update pol Modifier la couleur de %n conducteur(s) undo caption - - + Change the colour of %n conductor + Change the colour of %n conductors @@ -3045,12 +3045,12 @@ The element's display name is edited separately in the element properties.< Renvoi de folio suivant - Reference folio following + Next folio reference Renvoi de folio précédent - Previous reference folio + Previous folio reference @@ -3281,7 +3281,7 @@ The element's display name is edited separately in the element properties.< Réf. - Réf. + Ref. @@ -3629,7 +3629,7 @@ The element's display name is edited separately in the element properties.< Titre du folio - Title of folio + Folio title @@ -4040,16 +4040,16 @@ By importing this file, you confirm that: %n élément(s), répartie(s) - %n element, part - %n elements, parts + %n element, spread + %n elements, spread dans %n dossier(s). - in %n folder. - in %n folders. + across %n folder. + across %n folders. @@ -4215,7 +4215,7 @@ By importing this file, you confirm that: Remonter ce folio - Move up this folio + Move this folio up @@ -4225,17 +4225,17 @@ By importing this file, you confirm that: Remonter ce folio x10 - Move up this folio x10 + Move this folio up x10 Remonter ce folio x100 - Move up this folio x100 + Move this folio up x100 Remonter ce folio au debut - Move up this folio to the beginning + Move this folio to the beginning @@ -4381,7 +4381,7 @@ By importing this file, you confirm that: Titre du folio - Title of folio + Folio title @@ -4477,7 +4477,7 @@ By importing this file, you confirm that: Exporter entièrement le folio - Export fully folio + Export the whole folio @@ -4770,7 +4770,7 @@ that you create. Text and number inputs are Référence croisé - Cross reference + Cross-reference @@ -5609,8 +5609,8 @@ Any setting other than “No rounding” may cause rendering errors in the proje <center>ATTENTION :</center> il manque %1 lignes afin d'afficher l'intégralité des informations - <center>ATTENTION :</center> - lines are missing %1 to display all the informations + <center>WARNING:</center> + %1 more rows are needed to display all the information @@ -6195,13 +6195,13 @@ Please use the advanced editor for this. N° de folio - N° of folio + Folio no. Label de folio - Label folio + Folio label @@ -6209,7 +6209,7 @@ Please use the advanced editor for this. Titre de folio - Title of folio + Folio title @@ -6297,12 +6297,12 @@ Please use the advanced editor for this. Report de folio - Folio referencing + Folio reference Référence croisée (esclave) - Cross Reference (slave) + Cross-reference (slave) @@ -6364,18 +6364,18 @@ Please use the advanced editor for this. N° de folio - N° of folio + Folio no. Label de folio - Label folio + Folio label Titre de folio - Title of folio + Folio title @@ -6583,12 +6583,12 @@ Do you still want to link this slave contact? Reports de folio - Folio referencings + Folio references Références croisées - Cross References + Cross-references @@ -6816,7 +6816,7 @@ Do you still want to link this slave contact? N° folio - N° scheme + Folio no. @@ -7216,7 +7216,7 @@ Voltage / Protocol : %1 Numéro : %1 -Numéro : %1 +Number: %1 @@ -7265,7 +7265,7 @@ Conductor section : %1 Veuillez saisir une formule compatible pour ce potentiel. Les variables suivantes sont incompatibles : %sequf_ %seqtf_ %seqhf_ %id %F %M %LM - The new potential formula contains variables incompatible with the folio reports. + The new potential formula contains variables incompatible with folio references. Please enter a compatible formula for this potential. The following variables are incompatible: %sequf_ %seqtf_ %seqhf_ %id %F %M %LM @@ -8562,7 +8562,7 @@ Available options: Édite les propriétés du folio (dimensions, informations du cartouche, propriétés des conducteurs...) status bar tip - Edits the properties of the folio (size, title block informations, conductor properties...) + Edits the properties of the folio (size, title block information, conductor properties...) @@ -9172,12 +9172,12 @@ Hold Ctrl while moving to place freely. Ajoute une courbe de Bézier sur le folio actuel - Adds a Bézier curve on the current sheet + Adds a Bézier curve to the current folio Ajoute un plan de bornier sur le folio actuel - Add a terminal plan on the current sheet + Adds a terminal plan to the current folio @@ -9273,8 +9273,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b %n objet(s) remis sur la grille - %n item placed on the grid - %n item placed on the grid + %n item put back on the grid + %n items put back on the grid @@ -9387,8 +9387,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b %n élément(s) redessiné(s). - %n element redesigned. - %n elements redesigned. + %n element redrawn. + %n elements redrawn. @@ -9403,8 +9403,8 @@ Unbridge and/or remove the levels from the affected terminals so that they can b %n élément(s) non redessiné(s) : leur taille, leur point de saisie ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée). - %n element not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved). - %n elements not redrawn: their size, grip or boundaries have changed (a boundary has been added, removed or moved). + %n element not redrawn: its size, grip point or terminals have changed (a terminal was added, removed or moved). + %n elements not redrawn: their size, grip point or terminals have changed (a terminal was added, removed or moved). @@ -9858,7 +9858,7 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener <br><b>Erreur</b> :<br>Les reports de folio doivent posséder une seul borne.<br><b>Solution</b> :<br>Verifier que l'élément ne possède qu'une seul borne - <br> <b> Error </ b>: <br> folio referencings must have a single terminal <br> <b> Solution </ b> :<br> Check that the element has only one terminal + <br><b>Error</b>:<br>Folio references must have a single terminal.<br><b>Solution</b>:<br>Check that the element has only one terminal @@ -9871,8 +9871,8 @@ Enable scripts? This setting can be changed in Configure QElectroTech > Gener %n erreur(s) errors - %n erreur - %n erreurs + %n error + %n errors @@ -11138,8 +11138,8 @@ What do you wish to do ? %n forme(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - %n forme - %n formes + %n shape + %n shapes @@ -11252,7 +11252,7 @@ What do you wish to do ? Folio sans titre - Folio Untitled + Untitled folio @@ -11645,8 +11645,8 @@ the translated name of this folder could not be read, so its folder name is disp Ajouter %n conducteur(s) add a numbers of conductor one or more - add %n conductor - add %n conductors + Add %n conductor + Add %n conductors @@ -13849,7 +13849,7 @@ E.g. associating the name "volta" with the value "1745" will Label de report de folio - Label of folio referencing + Folio reference label @@ -13861,14 +13861,14 @@ Créer votre propre texte en vous aidant des variables suivantes : %LM : la localisation %l : le numéro de ligne %c : le numéro de colonne - You can define a custom label for folio reports. -Create your own text by using the following variables: -% f: the folio position in the project -% F: the folio number -% M: the installation -% LM: the location -% l: the line number -% c: the column number + You can define a custom label for folio references. +Create your own text using the following variables: +%f: the folio position in the project +%F: the folio number +%M: the installation +%LM: the location +%l: the row number +%c: the column number @@ -14031,7 +14031,7 @@ Create your own text by using the following variables: Eléments report de folio - Folio referencings elements + Folio reference elements @@ -17384,7 +17384,7 @@ The other fields are not used. Référence croisé - Cross reference + Cross-reference @@ -17673,7 +17673,7 @@ The other fields are not used. Informations des cartouches - Title block informations + Title block information @@ -18547,13 +18547,13 @@ associer le nom "volta" et la valeur "1745" remplacera %{vol %c : le numéro de colonne %M: Installation %LM: Localisation - Create your own text by helping you of the following variables : -%f : the folio number -% F: folio label -% l : the line number -% c : column number -% M: Plant -% LM: Location + Create your own text using the following variables: +%f: the folio number +%F: the folio label +%l: the row number +%c: the column number +%M: the installation +%LM: the location From 2ea279cc2b921d7bab849a221ebf86c40f9ead50 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 11:09:27 +1300 Subject: [PATCH 21/45] Fix bugtracker #105: straight wire drawn diagonally after a move updateConductorPath() fits a stored profile to the new terminal positions by sharing the horizontal difference over the profile's horizontal segments and the vertical one over its vertical segments. When a profile has no segment of non-zero length along an axis, the difference along that axis was dropped and the last point joined the terminal diagonally. On save that diagonal was written as one axis-aligned segment, so on reopen pathFromXml() found the lengths incoherent and rerouted the wire. Every straight wire with a stored path hits this after a reload, as a zero-length segment is saved as horizontal. Moving one end at a right angle to the wire, in the direction that keeps its path type, showed it. Generate a new path in that case, as is already done when there is no profile for the path type. Co-Authored-By: Claude Opus 5.5 --- sources/qetgraphicsitem/conductor.cpp | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/sources/qetgraphicsitem/conductor.cpp b/sources/qetgraphicsitem/conductor.cpp index f97fb97ec..4eb9009bc 100644 --- a/sources/qetgraphicsitem/conductor.cpp +++ b/sources/qetgraphicsitem/conductor.cpp @@ -228,9 +228,6 @@ void Conductor::segmentsToPath() @param o2 Orientation de la borne 2 */ void Conductor::updateConductorPath(const QPointF &p1, Qet::Orientation o1, const QPointF &p2, Qet::Orientation o2) { - Q_UNUSED(o1); - Q_UNUSED(o2); - ConductorProfile &conductor_profile = conductor_profiles[currentPathType()]; Q_ASSERT_X(conductor_profile.segmentsCount(QET::Both) > 1, "Conductor::priv_modifieConductor", "pas de points a modifier"); @@ -252,6 +249,25 @@ void Conductor::updateConductorPath(const QPointF &p1, Qet::Orientation o1, cons qreal h_diff = (qAbs(new_rect.width()) - qAbs(profile_width) ) * getSign(profile_width); qreal v_diff = (qAbs(new_rect.height()) - qAbs(profile_height)) * getSign(profile_height); + // A profile with no segment of non-zero length along an axis has nowhere + // to put a difference along that axis: it would be dropped and the last + // point would join the terminal diagonally. Every straight conductor + // loaded from a file is like this, as a zero-length segment is saved as + // horizontal (bugtracker #105). Generate a new path instead, as when + // there is no profile for this path type. + const auto can_absorb = [](qreal diff, const QList &segments_list) { + if (qAbs(diff) <= 0.01) return(true); + for (ConductorSegmentProfile *csp : segments_list) { + if (csp -> length) return(true); + } + return(false); + }; + if (!can_absorb(h_diff, conductor_profile.horizontalSegments()) || + !can_absorb(v_diff, conductor_profile.verticalSegments())) { + generateConductorPath(p1, o1, p2, o2); + return; + } + // applique les differences aux segments // apply the differences to the segments QMultiHash segments_lengths; From 03fc51d0fea8617f439377c06f265a66c7a9eeb9 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 11:15:26 +1300 Subject: [PATCH 22/45] qet-mcp: add --call, and document using it from the Claude desktop app A web chat in a browser cannot start a local program, so it cannot run this server. Two ways round that, documented in the README: - the Claude desktop app runs stdio servers; a step-by-step setup; - a chat that can execute Python can upload qet_mcp.py and run `--call ''` (or `-` to read the arguments from stdin). --call goes through the same dispatcher as the stdio server, so the workspace policy applies unchanged. Exit status 0 success, 1 the tool reported an error, 2 the call was malformed. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 63 ++++++++++++++++++++++++++++++++++++ misc/qet-mcp/qet_mcp.py | 42 ++++++++++++++++++++++++ misc/qet-mcp/test_qet_mcp.py | 41 +++++++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index c8e994d9b..999d8c0ae 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -54,6 +54,9 @@ misc/qet-mcp/qet_mcp.py --list # speak MCP on stdin/stdout misc/qet-mcp/qet_mcp.py + +# run one tool and exit, no MCP client needed +misc/qet-mcp/qet_mcp.py --call qet_project_info '{"path": "drawing.qet"}' ``` Register it with an MCP client, for example: @@ -73,6 +76,66 @@ Register it with an MCP client, for example: } ``` +## Using it from the Claude app + +A web chat in a browser cannot start a program on your computer, so it +cannot run this server. The Claude desktop app for Windows and macOS can, +and it uses the same account as the website. + +1. Install Python 3.9 or later. Nothing else is needed. +2. In the desktop app, open **Settings → Developer → Edit Config**. This + opens `claude_desktop_config.json`. +3. Add the server, with your own paths: + + ```json + { + "mcpServers": { + "qet": { + "command": "python", + "args": ["C:\\path\\to\\qelectrotech\\misc\\qet-mcp\\qet_mcp.py"], + "env": { + "QET_MCP_WORKSPACE": "C:\\Users\\you\\Documents\\drawings", + "QET_ENABLE_SCRIPTING": "1" + } + } + } + } + ``` + + On macOS use `python3` and ordinary `/` paths. In JSON every `\` in a + Windows path is written `\\`. +4. Quit the app completely and start it again. The tools appear under the + chat box's tools menu. +5. In the chat, say where your `qelectrotech` executable is. `qet_edit` and + `qet_export` take it as an argument on every call; the other tools do not + need it. + +Only files under `QET_MCP_WORKSPACE` can be read or written (see +[What the server is allowed to touch](#what-the-server-is-allowed-to-touch)). +Leave out `QET_ENABLE_SCRIPTING` if you do not want the assistant to edit +projects; see the next section for what that switches off. + +The test suite runs on Linux. The server uses nothing platform-specific, +but it has not yet been tested on Windows or macOS. + +### With only a browser + +If your web chat can run Python (on claude.ai, code execution), upload +`qet_mcp.py` together with your project and ask the assistant to use +`--call`: + +```bash +python3 qet_mcp.py --call qet_elements '{"path": "drawing.qet"}' +echo '{"path": "drawing.qet"}' | python3 qet_mcp.py --call qet_check - +``` + +It prints the tool's JSON result and exits 0, or 1 if the tool reported an +error, or 2 if the call itself was malformed. The workspace rule applies as +it does in a server. The sandbox has no QElectroTech in it, so only the +tools that read files work there: `qet_project_info`, `qet_elements`, +`qet_conductors`, `qet_items`, `qet_diff`, `qet_scan`, +`qet_element_info`, `qet_element_search` and `qet_element_build`. + ## Five tools need scripting switched on A QElectroTech with JavaScript scripting switched off refuses `--run`, and diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 09d84c384..f40090d96 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -47,6 +47,11 @@ PROTOCOL Line-delimited JSON-RPC 2.0 on stdin/stdout, per MCP's stdio transport. Nothing but protocol goes to stdout; diagnostics go to stderr. No third-party dependencies — the MCP SDK is not assumed to be present. + +`--call [arguments]` runs one tool without an MCP client, for an +assistant that can execute Python but cannot launch a server (a web chat +with code execution). It goes through the same dispatcher, so the +workspace policy applies exactly as it does over stdio. """ from __future__ import annotations @@ -3262,11 +3267,48 @@ def serve(stdin=sys.stdin, stdout=sys.stdout) -> None: print(json.dumps(reply, ensure_ascii=False), file=stdout, flush=True) +def call_once(argv: list[str], stdin=sys.stdin, stdout=sys.stdout, + stderr=sys.stderr) -> int: + """--call [arguments]: one tools/call, the result's text on stdout. + + arguments is a JSON object, or "-" to read it from stdin (which spares + the caller from quoting JSON for a shell). Exit status: 0 the tool + succeeded, 1 the tool reported an error, 2 the call itself was malformed. + """ + if not argv or len(argv) > 2: + print("usage: qet_mcp.py --call ['' | -]", + file=stderr) + return 2 + name, raw = argv[0], (argv[1] if len(argv) == 2 else "{}") + if raw == "-": + raw = stdin.read() + try: + arguments = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError as exc: + print(f"arguments are not valid JSON: {exc}", file=stderr) + return 2 + if not isinstance(arguments, dict): + print("arguments must be a JSON object", file=stderr) + return 2 + + reply = handle({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": name, "arguments": arguments}}) + if "error" in reply: + print(reply["error"]["message"], file=stderr) + return 2 + result = reply["result"] + for part in result["content"]: + print(part["text"], file=stdout) + return 1 if result.get("isError") else 0 + + def main() -> int: if len(sys.argv) > 1 and sys.argv[1] in ("--list", "-l"): for t in TOOLS: print(f"{t['name']}\n {t['description']}\n") return 0 + if len(sys.argv) > 1 and sys.argv[1] == "--call": + return call_once(sys.argv[2:]) serve() return 0 diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index fdac11f55..51aaa89d0 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2159,6 +2159,47 @@ class Protocol(unittest.TestCase): for t in m.TOOLS: self.assertIn(t["name"], out) + def call(self, *args, stdin="", workspace=None): + env = dict(os.environ) + if workspace is not None: + env["QET_MCP_WORKSPACE"] = str(workspace) + return subprocess.run([sys.executable, str(HERE / "qet_mcp.py"), "--call", *args], + input=stdin, capture_output=True, text=True, + timeout=30, env=env) + + def test_call_flag_runs_one_tool(self): + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "a.qet" + p.write_text('') + by_argv = self.call("qet_project_info", json.dumps({"path": str(p)}), + workspace=tmp) + by_stdin = self.call("qet_project_info", "-", + stdin=json.dumps({"path": str(p)}), workspace=tmp) + for proc in (by_argv, by_stdin): + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(proc.stdout)["title"], "Via --call") + + def test_call_flag_keeps_the_workspace_policy(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) / "ws" + root.mkdir() + victim = Path(tmp) / "elsewhere.qet" + victim.write_text('') + proc = self.call("qet_project_info", json.dumps({"path": str(victim)}), + workspace=root) + self.assertEqual(proc.returncode, 1) + self.assertIn("outside the workspace", proc.stdout) + self.assertNotIn("not yours", proc.stdout) + + def test_call_flag_exit_codes(self): + tool_error = self.call("qet_project_info", '{"path": "/no/such.qet"}') + self.assertEqual(tool_error.returncode, 1) + for args in (("nope",), ("qet_project_info", "{not json"), + ("qet_project_info", "[1]"), ()): + proc = self.call(*args) + self.assertEqual(proc.returncode, 2, args) + self.assertEqual(proc.stdout, "", args) + # ========================================================================== # integration From 94b0d8d06dfd8b7554f403883036feaf1b62b374 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 11:29:14 +1300 Subject: [PATCH 23/45] qet-mcp: a folio's "$id" keeps naming it after a later insert or removal The "$id" of an add_folio or insert_folio held the index the folio had when it was made, so a later insert_folio or remove_folio in the same qet_edit run shifted it, and ops naming "$id" edited the wrong folio -- the case #1115 fixed for folios given by uuid, left open for these. The script now also keeps such a folio's uuid (qet.folioUuid()) and resolves "$id", where an op takes a folio, through qet.folioIndex() at the moment it is used. On a build without folioUuid() it falls back to the stored index, as before, and nothing new is required of the binary. The op's reported result is still the index. Test: add_folio "$f" (index 1), insert_folio at 0, set_folio_title "$f": the title lands on the third folio -- on the second without this change. Two script-generation tests updated for the new expression. 255/255. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 4 ++++ misc/qet-mcp/qet_mcp.py | 20 ++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 43 +++++++++++++++++++++++++++++++++--- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index ed0613021..4c704ad91 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -359,6 +359,10 @@ Python, plus the hang guard on `addConductor` and the database refresh in an index would shift. A folio saved without a uuid shows it empty: QElectroTech gives it one on load and writes it on the next save, so it appears after a first `qet_edit`. Needs `qet.folioIndex()` in the build. + The `"$id"` of an `add_folio` or `insert_folio` works the same way: it + keeps naming that folio after a later `insert_folio` or `remove_folio` in + the same run (on a build without `qet.folioUuid()`, it is the index the + folio had when it was made, as before). - **A conductor can be named by its uuid** (`qet_conductors` reports it): `set_conductor`, `move_conductor_segment` and `delete_conductor` take `"conductor": "{uuid}"` in place of `element` + `terminal`, which works diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index fa3363d7e..b1713ab84 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1430,6 +1430,10 @@ SEARCH_REPLACE_KINDS = ["element_info", "conductor", "text"] FOLIO_PROPERTIES = ["title", "author", "filename", "plant", "locmach", "indexrev", "folio", "template"] +# The ops that make a folio: their "$id" is an index the next insert_folio +# or remove_folio can shift, so the script also keeps the folio's uuid. +FOLIO_MAKING_OPS = ("add_folio", "insert_folio") + # The ops that address one conductor by element + terminal, and so also # take "conductor": "{uuid}" (qet_conductors reports each one's uuid). CONDUCTOR_UUID_OPS = ("set_conductor", "move_conductor_segment", "delete_conductor") @@ -1469,6 +1473,9 @@ def _build_script(operations: list, output: str) -> str: JavaScript exception. """ refs: set[str] = set() + # "$name"s made by an op that creates a folio: held as the folio's uuid + # too, since a later insert_folio or remove_folio shifts its index. + folio_refs: set[str] = set() # Resolvers a uuid reference needs; required only when one is used, so # an index-only edit still runs on a build that predates them. uuid_methods: set[str] = set() @@ -1478,6 +1485,7 @@ def _build_script(operations: list, output: str) -> str: lines = [ "// generated by qet-mcp; do not edit", "var R = {};", # $name -> value from an earlier op + "var F = {};", # $name -> uuid of a folio an op made "var missing = [];", "var need = @NEED@;", "for (var i = 0; i < need.length; i++) {", @@ -1550,6 +1558,11 @@ def _build_script(operations: list, output: str) -> str: raise ValueError( f"operation {op_index} refers to {value!r}, which no earlier " f"operation defined (set \"id\": {name!r} on the op that creates it)") + if kind == "folio" and name in folio_refs: + # the folio's index now, not when it was made; the stored + # index on a build that cannot report folio uuids + ref = _js(name) + return f"(F[{ref}] ? qet.folioIndex(F[{ref}]) : R[{ref}])" return f"R[{_js(name)}]" if kind == "num": if not isinstance(value, (int, float)) or isinstance(value, bool): @@ -1752,6 +1765,10 @@ def _build_script(operations: list, output: str) -> str: if ident is not None: lines.append(f" R[{_js(ident)}] = v{i};") refs.add(ident) + if name in FOLIO_MAKING_OPS: + lines.append(f" F[{_js(ident)}] = (typeof qet.folioUuid === 'function' " + f"&& v{i} >= 0) ? qet.folioUuid(v{i}) : '';") + folio_refs.add(ident) lines.append( f" qet.log({_js(_MARKER)} + JSON.stringify(" f"{{kind: 'op', index: {i}, op: {_js(name)}, " @@ -2669,7 +2686,8 @@ TOOLS = [ "qet_project_info call 1 is \"folio\": 0 here. A folio can " "be given as its uuid instead (qet_project_info lists them), " "which still names the same folio after an earlier op adds or " - "removes one. A terminal (\"terminal\", \"from_terminal\", " + "removes one; so does the \"$id\" of an add_folio or " + "insert_folio. A terminal (\"terminal\", \"from_terminal\", " "\"to_terminal\") is given by its index -- qet_element_info " "lists terminals in index order, top to bottom then left to " "right -- or by its uuid, which qet_element_info also lists and " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 24c1e2718..2d55c0c20 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -184,7 +184,8 @@ class EditValidation(unittest.TestCase): s = self.build([{"op": "add_folio", "id": "f"}, {"op": "add_element", "id": "k", "folio": "$f", "path": "p", "x": 0, "y": 0}, {"op": "delete_element_text", "folio": "$f", "element": "$k", "index": U}]) - self.assertIn(f'qet.deleteElementText(R["f"], R["k"], qet.elementTextIndex(R["f"], R["k"], "{U}"))', s) + f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio + self.assertIn(f'qet.deleteElementText({f}, R["k"], qet.elementTextIndex({f}, R["k"], "{U}"))', s) # the lookups are required only when a uuid is used self.assertIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": U}])) self.assertNotIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": 0}])) @@ -214,6 +215,27 @@ class EditValidation(unittest.TestCase): with self.assertRaisesRegex(ValueError, "folio index, its uuid"): self.build([{"op": "set_folio", "folio": "first", "property": "author", "value": "a"}]) + def test_folio_ref_follows_the_folio(self): + """A "$name" made by add_folio or insert_folio is looked up by the + folio's uuid when used as a folio, since a later insert or removal + shifts its index; the stored index is the fallback on a build that + cannot report folio uuids. Used as anything else, it is unchanged.""" + s = self.build([{"op": "add_folio", "id": "f"}, + {"op": "insert_folio", "id": "g", "position": 0}, + {"op": "set_folio_title", "folio": "$f", "title": "t"}, + {"op": "set_folio_title", "folio": "$g", "title": "u"}]) + self.assertIn("F[\"f\"] = (typeof qet.folioUuid === 'function' && v0 >= 0) " + "? qet.folioUuid(v0) : '';", s) + self.assertIn('qet.setFolioTitle((F["f"] ? qet.folioIndex(F["f"]) : R["f"]), "t")', s) + self.assertIn('qet.setFolioTitle((F["g"] ? qet.folioIndex(F["g"]) : R["g"]), "u")', s) + # not required: an edit still runs on a build without folio uuids + self.assertNotIn('"folioUuid"', s) + # a "$name" from any other op is untouched + s = self.build([{"op": "add_text", "id": "t", "folio": 0, "text": "x", "x": 0, "y": 0}, + {"op": "delete_text", "folio": 0, "index": "$t"}]) + self.assertIn('R["t"]', s) + self.assertNotIn("F[", s.split("if (missing.length === 0) {")[1]) + def test_conductor_by_uuid(self): """A conductor named by uuid is turned into one of its ends at run time; the lookup is required only then, and "conductor" cannot be @@ -267,7 +289,8 @@ class EditValidation(unittest.TestCase): s = self.build([{"op": "add_folio", "id": "f"}, {"op": "add_element", "id": "a", "folio": "$f", "path": "x.elmt", "x": 0, "y": 0}, {"op": "delete_conductor", "folio": "$f", "element": "$a", "terminal": T}]) - self.assertIn(f'qet.deleteConductor(R["f"], R["a"], qetMcpTerminal(2, "terminal", R["f"], R["a"], "{T}"))', s) + f = '(F["f"] ? qet.folioIndex(F["f"]) : R["f"])' # a folio "$name": see test_folio_ref_follows_the_folio + self.assertIn(f'qet.deleteConductor({f}, R["a"], qetMcpTerminal(2, "terminal", {f}, R["a"], "{T}"))', s) s = self.build([{"op": "delete_conductor", "folio": V, "element": E, "terminal": T}]) self.assertIn(f'qetMcpTerminal(0, "terminal", qet.folioIndex("{V}"), "{E}", "{T}")', s) # an index is unchanged and needs no lookup @@ -521,7 +544,8 @@ class EditValidation(unittest.TestCase): script = self.build([{"op": "add_folio", "id": "f"}, {"op": "set_folio_title", "folio": "$f", "title": nasty}]) # the literal must be valid JSON, so JavaScript reads exactly what was sent - literal = re.search(r'setFolioTitle\(R\["f"\], (".*?")\)', script, re.S).group(1) + literal = re.search(r'setFolioTitle\(\(F\["f"\] \? qet\.folioIndex\(F\["f"\]\) : R\["f"\]\), (".*?")\)', + script, re.S).group(1) self.assertEqual(json.loads(literal), nasty) def test_edit_refuses_in_place_and_empty(self): @@ -2355,6 +2379,19 @@ class Integration(unittest.TestCase): note = r["operations"][1].get("note", "") self.assertIn("from_terminal: no terminal", note) self.assertIn("to_terminal: no terminal", note) + def test_folio_ref_survives_an_insert_before_it(self): + """add_folio names a folio "$f"; inserting another at position 0 + moves it from index 1 to 2. "$f" must still name it.""" + base = self.sb.new() + r = self.ok(self.sb.edit(base, [ + {"op": "add_folio", "id": "f"}, + {"op": "insert_folio", "position": 0}, + {"op": "set_folio_title", "folio": "$f", "title": "MINE"}])) + self.assertEqual(r["operations"][0]["result"], 1) + titles = [f["title"] for f in m.tool_project_info(r["output"])["folios"]] + self.assertEqual(len(titles), 3) + self.assertEqual(titles[2], "MINE", titles) + self.assertNotIn("MINE", titles[:2]) def test_noop_edit_has_no_conductor_churn(self): """Re-saving renumbers the file's terminal ids; the diff must not From fdef38eba8cdcfa8e13a216c28fc94d5a1c05a7e Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 12:09:08 +1300 Subject: [PATCH 24/45] qet-mcp: tests for what a second mutation audit found untested tools/qet-mcp-audit/mutate.py over the code changed since #1095 (_build_script, the diff helpers, _parse_script_output, tool_items and the wire-end keys of #1118), tests needing no QElectroTech only: master f530b7636's suite noticed 531 of 578 planted bugs (92 %). With these tests, 568 (98 %). The 10 left are equivalent: rsplit("/", 1) vs 2, a 1e-6 tolerance compared with < or <=, a one-letter orientation sliced [:1] or [:2], branches that only touch parts without terminals, and the placeholder terminal 0 a conductor uuid overwrites. - Every argument kind refuses what it cannot take, before any launch: malformed indices, bool, points and nodes, search_and_replace with an unknown kind or conductor field or an empty element_info field, "conductor" given with "element" or "terminal" alone, an op that is not an object -- and the same kinds accept what they should. - _parse_script_output reports capabilities (none logged = unknown, not "nothing missing"), notes on the right op, save and stopped_early; ignores a line without the marker even when it would parse, and a line of a kind it does not know. - A uuid wire end in a column of terminals (same x) keys like the numbered one: the match is on x, y and orientation together. Tests only; 264/264. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/test_qet_mcp.py | 131 +++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 24c1e2718..d9f3fad0f 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -458,6 +458,80 @@ class EditValidation(unittest.TestCase): need = json.loads(re.search(r"var need = (\[.*?\]);", script).group(1)) self.assertFalse({"shapeIndex", "textIndex", "imageIndex"} & set(need)) + def test_malformed_arguments_are_refused(self): + """Each argument kind rejects what it cannot take, before any launch. + Found by the mutation audit: forcing any of these checks off went + unnoticed.""" + f = {"op": "add_folio", "id": "f"} + cases = [ + ("indices", {"op": "group_terminals", "strip": 0, "indices": []}), + ("indices", {"op": "group_terminals", "strip": 0, "indices": [1, "2"]}), + ("indices", {"op": "group_terminals", "strip": 0, "indices": [True]}), + ("indices", {"op": "bridge_terminals", "strip": 0, "indices": 3}), + ("true or false", {"op": "add_polygon", "folio": 0, "closed": 1, + "points": [{"x": 0, "y": 0}, {"x": 1, "y": 1}]}), + ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}]}), + ("at least 2", {"op": "add_polygon", "folio": 0, "closed": True, "points": "xy"}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, {"x": "1", "y": 1}]}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, {"x": True, "y": 1}]}), + ("entries must be", {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0}, [1, 1]]}), + ("at least 2 nodes", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}]}), + ("corner, smooth or symmetric", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, + {"x": 1, "y": 1, "kind": "sharp"}]}), + ("inHandle", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "inHandle": [0, 0]}]}), + ("outHandle", {"op": "add_path", "folio": 0, "closed": False, + "nodes": [{"x": 0, "y": 0}, {"x": 1, "y": 1, "outHandle": {"x": 0}}]}), + ("unknown kind", {"op": "search_and_replace", "kind": "folio", "field": "x", + "pattern": "a", "replacement": "b", "regex": False, + "case_sensitive": False}), + ("unknown conductor field", {"op": "search_and_replace", "kind": "conductor", + "field": "label", "pattern": "a", "replacement": "b", + "regex": False, "case_sensitive": False}), + ("non-empty", {"op": "search_and_replace", "kind": "element_info", "field": "", + "pattern": "a", "replacement": "b", "regex": False, + "case_sensitive": False}), + ("not both", {"op": "delete_conductor", "folio": 0, + "conductor": "{11111111-2222-4333-8444-555555555555}", + "element": "{11111111-2222-4333-8444-555555555555}"}), + ("not both", {"op": "delete_conductor", "folio": 0, + "conductor": "{11111111-2222-4333-8444-555555555555}", "terminal": 0}), + ("not an object", "add_folio"), + ] + for message, op in cases: + with self.subTest(op=op): + with self.assertRaisesRegex(ValueError, message): + self.build([f, op]) + + def test_valid_shapes_of_those_arguments_pass(self): + """The other side: the same kinds accept what they should, so the + refusals above are not everything failing.""" + s = self.build([ + {"op": "add_polygon", "folio": 0, "closed": False, + "points": [{"x": 0, "y": 0}, {"x": 1.5, "y": -2}]}, + {"op": "add_path", "folio": 0, "closed": True, + "nodes": [{"x": 0, "y": 0, "kind": "smooth", "inHandle": {"x": 1, "y": 1}}, + {"x": 5, "y": 5, "outHandle": {"x": 2, "y": 2}}]}, + {"op": "add_polygon", "folio": 0, "closed": True, + "points": [{"x": 0, "y": 0, "kind": "anything"}, {"x": 1, "y": 1}]}, + {"op": "search_and_replace", "kind": "conductor", "field": "num", "pattern": "a", + "replacement": "b", "regex": True, "case_sensitive": False}, + {"op": "search_and_replace", "kind": "text", "field": "", "pattern": "a", + "replacement": "b", "regex": False, "case_sensitive": True}]) + self.assertIn("qet.addPolygon(0, [{", s) + self.assertIn("qet.searchAndReplace(\"conductor\", \"num\", \"a\", \"b\", true, false)", s) + self.assertIn("qet.searchAndReplace(\"text\", \"\", \"a\", \"b\", false, true)", s) + + def test_no_id_stores_nothing(self): + s = self.build([{"op": "add_folio"}]) + self.assertNotIn("R[", s.split("if (missing.length === 0) {")[1]) + def test_a_string_index_that_is_not_a_uuid_is_refused(self): for bad in ("3", "{nope}", "11111111-1111"): with self.subTest(index=bad): @@ -566,6 +640,37 @@ class ResultParsing(unittest.TestCase): self.assertEqual(parsed["operations"], []) + def test_capabilities_notes_and_save(self): + """Every field the script reports reaches the result, attached where + it belongs. Found by the mutation audit: only the binary tests read + these back.""" + text = "\n".join([ + self.line(kind="capabilities", missing=["addFolio"]), + self.line(kind="op", index=0, op="a", id=None, result=True), + self.line(kind="op", index=1, op="b", id=None, result=False), + self.line(kind="op_note", index=1, note="why"), + self.line(kind="save", result=True, stopped_early=True), + self.line(kind="something_newer", result=False)]) # not a save + parsed = m._parse_script_output(text) + self.assertEqual(set(parsed), {"missing_methods", "operations", "saved", "stopped_early"}) + self.assertEqual(parsed["missing_methods"], ["addFolio"]) + self.assertTrue(parsed["saved"]) + self.assertTrue(parsed["stopped_early"]) + self.assertNotIn("note", parsed["operations"][0]) + self.assertEqual(parsed["operations"][1]["note"], "why") + # no capabilities line: unknown, not "nothing missing" + self.assertIsNone(m._parse_script_output(self.line(kind="save", result=False))["missing_methods"]) + self.assertEqual(m._parse_script_output(self.line(kind="capabilities", missing=None)) + ["missing_methods"], []) + + def test_a_line_without_the_marker_is_ignored(self): + """Even one that would parse if read from where the marker would be.""" + almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "save", "result": True}) + parsed = m._parse_script_output(almost) + self.assertIsNone(parsed["saved"]) + self.assertFalse(parsed["stopped_early"]) + + class TerminalOrder(unittest.TestCase): """QElectroTech indexes terminals top-to-bottom then left-to-right, not in file order. Getting it wrong wires the wrong end of a coil with no error; @@ -962,6 +1067,32 @@ class ReadToolContracts(unittest.TestCase): self.assertEqual(numbered, "1:{E}@0,-4,2--{E}@6,0,1") self.assertEqual(by_uuid, numbered) + def test_conductor_key_same_in_both_forms_in_a_column(self): + """Terminals one above the other share their x: a uuid end must be + matched on x, y and orientation together, not on any one of them. + Found by the mutation audit: an "or" in place of "and" went + unnoticed while every test's terminals differed in every coordinate.""" + root = ET.fromstring( + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '') + numbered, by_uuid = [m._conductor_row(i, c, ix)["key"] + for i, c, ix in m._conductors(root)] + self.assertEqual(numbered, "1:{E}@0,24,0--{E}@30,24,0") + self.assertEqual(by_uuid, numbered) + def test_conductor_row_without_an_index(self): c = ET.fromstring('') self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8") From 124b7e4f6e90100a3d2f64fafa52e6c1917df4d4 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 12:11:03 +1300 Subject: [PATCH 25/45] qet-mcp: stop a tool call choosing the program the server runs qet_export, qet_edit, qet_query, qet_continuity, qet_check and qet_project_new took the QElectroTech executable as a per-call argument and ran whatever executable file it named, with the call's own paths as arguments. The workspace policy exempted it as configuration, but it is chosen by the model on every call, so text inside a project could steer an assistant into starting another program. The server now finds QElectroTech itself: QET_BINARY, then the install it ships in (/share/qelectrotech/mcp/), then PATH. "binary" becomes optional; when given it must be that same file (after resolving symlinks) or one listed in QET_MCP_BINARIES. QET_MCP_ALLOW_ANY_BINARY=1 restores the old behaviour, as QET_MCP_ALLOW_ANY_PATH does for paths. "elements_dir" defaults to the installed collection and, when given, must be in the workspace, that collection, or QET_MCP_ELEMENTS. Both checks live in enforce_path_policy(), the one place tool arguments enter. test_configuration_paths_are_exempt asserted the old exemption and is replaced by BinaryPolicy (13 tests) and a stdio test of the original reproduction. Each new check was removed in turn and the tests failed. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 39 +++++--- misc/qet-mcp/qet_mcp.py | 160 +++++++++++++++++++++++++++----- misc/qet-mcp/test_qet_mcp.py | 172 +++++++++++++++++++++++++++++++---- 3 files changed, 321 insertions(+), 50 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index ed0613021..f0640ea4f 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -69,6 +69,7 @@ Register it with an MCP client, for example: "args": ["/path/to/qelectrotech/misc/qet-mcp/qet_mcp.py"], "env": { "QET_MCP_WORKSPACE": "/home/you/drawings", + "QET_BINARY": "/usr/bin/qelectrotech", "QET_ENABLE_SCRIPTING": "1" } } @@ -76,6 +77,11 @@ Register it with an MCP client, for example: } ``` +`QET_BINARY` is the QElectroTech the tools launch. Leave it out when +`qelectrotech` is on your `PATH`, or when the server is installed with +QElectroTech (as `/share/qelectrotech/mcp/qet_mcp.py`, which also +finds the installed element collection). + ## Using it from the Claude app A web chat in a browser cannot start a program on your computer, so it @@ -106,9 +112,9 @@ and it uses the same account as the website. Windows path is written `\\`. 4. Quit the app completely and start it again. The tools appear under the chat box's tools menu. -5. In the chat, say where your `qelectrotech` executable is. `qet_edit` and - `qet_export` take it as an argument on every call; the other tools do not - need it. +5. If `qelectrotech` is not on your `PATH`, add `"QET_BINARY"` to the + `env` block with the full path to the executable + (`C:\\Program Files\\...\\qelectrotech.exe`, written with `\\`). Only files under `QET_MCP_WORKSPACE` can be read or written (see [What the server is allowed to touch](#what-the-server-is-allowed-to-touch)). @@ -179,11 +185,22 @@ Set the workspace to the folder your drawings live in. A path outside it is refused with an error naming what was allowed; symlinks are resolved first, so a link planted inside the workspace is judged by where it points. -Two arguments are deliberately **not** confined: `binary` (the -`qelectrotech` executable) and `elements_dir` (the element collection). -Those are configuration, chosen once by whoever runs the server, and both -normally live in `/usr` or a build tree — outside any sensible workspace. -Confining them would reject the ordinary case while stopping nothing. +**The client does not choose what program runs.** The tools that launch +QElectroTech use the one the server found (`QET_BINARY`, the install it +ships in, or `PATH`). A call may still name `binary`, but only as that same +file or one listed by whoever configured the server: + +| | | +|---|---| +| `QET_BINARY` | the QElectroTech to launch | +| `QET_MCP_BINARIES` | other executables a call may name, separated like `QET_MCP_WORKSPACE` (for comparing two builds) | +| `QET_MCP_ALLOW_ANY_BINARY=1` | turns the check off: a call can then run any program | +| `QET_MCP_ELEMENTS` | element collections a call may name as `elements_dir` besides the workspace and the installed one | + +Anything else is refused, even a file inside the workspace: being there +makes it readable, not runnable. Before this rule any executable a call +named was run, with the call's own paths as arguments, so text inside a +project could steer an assistant into starting another program. `QET_MCP_ALLOW_ANY_PATH=1` is equivalent to granting the client local filesystem access with this process's privileges. It exists so that is a @@ -221,7 +238,6 @@ the answer a screenshot gave wrongly. ```json {"name": "qet_edit", "arguments": { - "binary": "/path/to/qelectrotech", "project": "in.qet", "output": "out.qet", "elements_dir": "/path/to/qelectrotech/elements", "operations": [ @@ -277,7 +293,7 @@ to emit. ```json {"name": "qet_query", "arguments": { - "binary": "/path/to/qelectrotech", "project": "industrial.qet", + "project": "industrial.qet", "sql": "SELECT label, COUNT(*) AS n FROM element_nomenclature_view WHERE label <> '' GROUP BY label HAVING n > 1 ORDER BY n DESC"}} ``` @@ -420,7 +436,8 @@ Python, plus the hang guard on `addConductor` and the database refresh in changes nothing. `addElement` and the move/delete verbs shipped with the scripting API; `addConductor`, `rotateElement`, `setElementLabel`, `setElementInfo` and `setFolioTitle` are newer. -- **`elements_dir` is not optional for `common://` paths.** The sandboxed +- **`elements_dir` is not optional for `common://` paths** unless the + server is installed with QElectroTech, which fills it in. The sandboxed run has its own empty HOME, so QElectroTech falls back to the compiled-in collection path, which on a machine that never ran `make install` does not exist. The only symptom is `addElement` reporting that a file plainly diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index fa3363d7e..485a53bf8 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -2622,7 +2622,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string"}, "format": {"type": "string", "enum": sorted(EXPORT_FORMATS)}, "output": {"type": "string"}, @@ -2631,7 +2631,7 @@ TOOLS = [ "without this an existing file is never clobbered"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project", "format", "output"], + "required": ["project", "format", "output"], }, "handler": lambda a: tool_export(a["binary"], a["project"], a["format"], a["output"], a.get("timeout", 180)), @@ -2649,7 +2649,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string", "description": "the .qet to start from; not modified"}, "output": {"type": "string", "description": "where to write the edited project"}, "overwrite": {"type": "boolean", "default": False, @@ -2867,7 +2867,7 @@ TOOLS = [ }, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project", "output", "operations"], + "required": ["project", "output", "operations"], }, "handler": lambda a: tool_edit(a["binary"], a["project"], a["operations"], a["output"], a.get("elements_dir"), @@ -2885,7 +2885,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string", "description": "the .qet to query; never modified"}, "sql": {"type": "string", "description": "a single SELECT or WITH...SELECT. " @@ -2893,7 +2893,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project"], + "required": ["project"], }, "handler": lambda a: tool_query(a["binary"], a["project"], a.get("sql", ""), a.get("elements_dir"), a.get("timeout", 180)), @@ -2920,7 +2920,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string", "description": "the .qet to check; never modified"}, "folio": {"type": "integer", "description": "check one folio only; omit for the whole project. An index " @@ -2931,7 +2931,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project"], + "required": ["project"], }, "handler": lambda a: tool_continuity(a["binary"], a["project"], a.get("folio"), a.get("elements_dir"), a.get("timeout", 180)), @@ -2947,7 +2947,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "output": {"type": "string", "description": "where to write the new .qet"}, "title": {"type": "string", "description": "the project title"}, "folios": {"description": "how many empty folios, or a list of folio titles", @@ -2959,7 +2959,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "output", "title"], + "required": ["output", "title"], }, "handler": lambda a: tool_project_new( a["binary"], a["output"], a["title"], a.get("folios", 1), a.get("author", ""), @@ -3008,7 +3008,7 @@ TOOLS = [ "inputSchema": { "type": "object", "properties": { - "binary": {"type": "string", "description": "path to the qelectrotech executable"}, + "binary": {"type": "string", "description": "the qelectrotech executable; leave it out to use the one this server is configured with. Any other is refused unless its configuration allows it"}, "project": {"type": "string"}, "checks": {"type": "array", "items": {"type": "string", "enum": sorted(CHECKS)}, "description": "which checks to run; omit for all"}, @@ -3017,7 +3017,7 @@ TOOLS = [ "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, - "required": ["binary", "project"], + "required": ["project"], }, "handler": lambda a: tool_check(a["binary"], a["project"], a.get("checks"), a.get("sample", 10), a.get("elements_dir"), @@ -3097,17 +3097,21 @@ _BY_NAME = {t["name"]: t for t in TOOLS} # sandboxed HOME each QElectroTech launch gets isolates *settings*, not the # filesystem. # -# So data paths are confined to a workspace. Two kinds of path are treated -# differently, deliberately: +# So data paths are confined to a workspace, and the program the server +# launches is not the client's to choose: # # data chosen by the client per call -- the projects, directories, -# images and outputs below. Confined. -# configuration chosen once by whoever runs the server -- "binary" (the -# qelectrotech executable) and "elements_dir" (the element -# collection). Both normally live in /usr or a build tree, -# i.e. outside any sane workspace, so confining them would -# reject the ordinary case while stopping nothing: they are -# not where a model gets to point the server at /etc. +# images and outputs below. Confined to the workspace. +# executable "binary". Resolved by the server itself (resolve_binary()); +# a client may name it only when it is that same file or one +# whoever configured the server listed in QET_MCP_BINARIES. +# It once counted as configuration and went unchecked, but it +# is a per-call argument: a model steered by text in a +# project could run any program on the machine with it. +# collection "elements_dir". Normally outside the workspace (in /usr or +# a build tree), so allowed there, in the collection of the +# resolved install, or in a directory listed in +# QET_MCP_ELEMENTS. # # Enforced here, at the dispatcher, because this is the trust boundary -- # the point where model-supplied arguments enter. Calling the tool_* helpers @@ -3131,6 +3135,10 @@ _DATA_PATHS = { "qet_element_build": {"write": ("output",)}, } +# Tools that launch QElectroTech, and so take "binary" and "elements_dir". +_LAUNCHES_QET = {"qet_export", "qet_edit", "qet_query", "qet_continuity", + "qet_check", "qet_project_new"} + # qet_edit operations that name a file of their own. _DATA_PATH_OPS = {"add_image": "file", "add_pdf_page": "file"} @@ -3169,6 +3177,106 @@ def _within_workspace(path: Path, roots: list) -> bool: return False +def _env_paths(name: str) -> list: + """An os.pathsep-separated list of paths from the environment, resolved.""" + out = [] + for part in os.environ.get(name, "").split(os.pathsep): + if part.strip(): + try: + out.append(Path(part).expanduser().resolve()) + except OSError: + continue + return out + + +def _installed_prefix() -> Path | None: + """The install prefix when this script is /share/qelectrotech/mcp/.""" + here = Path(__file__).resolve().parent + if here.name == "mcp" and here.parent.name == "qelectrotech" \ + and here.parent.parent.name == "share": + return here.parent.parent.parent + return None + + +def resolve_binary() -> Path | None: + """The QElectroTech this server launches, found without asking the client. + + QET_BINARY first, then the install this script ships in, then + qelectrotech on PATH. None when there is none; the tools that launch + QElectroTech then say how to set it. + """ + env = os.environ.get("QET_BINARY", "").strip() + if env: + return Path(env).expanduser().resolve() + prefix = _installed_prefix() + if prefix is not None: + for name in ("qelectrotech", "qelectrotech.exe"): + cand = prefix / "bin" / name + if cand.is_file(): + return cand.resolve() + found = shutil.which("qelectrotech") + return Path(found).resolve() if found else None + + +def default_elements_dir() -> Path | None: + """The element collection of the install this script ships in, if any.""" + prefix = _installed_prefix() + if prefix is not None: + coll = prefix / "share" / "qelectrotech" / "elements" + if coll.is_dir(): + return coll.resolve() + return None + + +def _check_binary(arguments: dict) -> None: + """Fill in "binary", or refuse one that is not the server's own choice.""" + if os.environ.get("QET_MCP_ALLOW_ANY_BINARY") == "1" and arguments.get("binary"): + return + default = resolve_binary() + raw = arguments.get("binary") + if not raw: + if default is None: + raise ValueError( + "no QElectroTech found: set QET_BINARY to the qelectrotech " + "executable in the environment this server is started in") + arguments["binary"] = str(default) + return + if not isinstance(raw, str): + raise ValueError("'binary' must be a path") + given = Path(raw).expanduser().resolve() + allowed = ([default] if default else []) + _env_paths("QET_MCP_BINARIES") + if given not in allowed: + raise ValueError( + f"'binary' is not an allowed QElectroTech: {given}. Leave it out " + "to use " + (str(default) if default else "QET_BINARY") + + "; whoever configured this server can list others in " + "QET_MCP_BINARIES, or set QET_MCP_ALLOW_ANY_BINARY=1 to " + "disable this check (which lets the client run any program).") + arguments["binary"] = str(given) + + +def _check_elements_dir(arguments: dict, roots: list) -> None: + """Fill in "elements_dir" from the install, or confine a given one.""" + raw = arguments.get("elements_dir") + if not raw: + default = default_elements_dir() + if default is not None: + arguments["elements_dir"] = str(default) + return + if not isinstance(raw, str): + raise ValueError("'elements_dir' must be a path") + given = Path(raw).expanduser().resolve() + extra = _env_paths("QET_MCP_ELEMENTS") + default = default_elements_dir() + if default is not None: + extra.append(default) + if roots and not _within_workspace(given, roots + extra): + raise ValueError( + f"'elements_dir' is outside the workspace: {given}. Leave it out " + "to use the installed collection, or list the directory in " + "QET_MCP_ELEMENTS.") + + def _check_path(raw, arg: str, mode: str, roots: list) -> Path: """Resolve one path and refuse it if it leaves the workspace. @@ -3192,12 +3300,20 @@ def _check_path(raw, arg: str, mode: str, roots: list) -> Path: def enforce_path_policy(tool_name: str, arguments: dict) -> None: - """Apply the workspace and overwrite policy to one tool call.""" + """Apply the workspace, executable and overwrite policy to one tool call. + + For a tool that launches QElectroTech this also fills in "binary" and, + when the install has one, "elements_dir", so a client need not know them. + """ spec = _DATA_PATHS.get(tool_name) if spec is None: return roots = workspace_roots() + if tool_name in _LAUNCHES_QET: + _check_binary(arguments) + _check_elements_dir(arguments, roots) + for arg in spec.get("read", ()): if arg in arguments: _check_path(arguments[arg], arg, "read", roots) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 24c1e2718..6c98569d8 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -74,6 +74,16 @@ PLC_MASTER = "common://plc_master_test.elmt" PLC_SLAVE = "common://plc_slave_test.elmt" +def fake_qet(bindir: Path, name: str = "qelectrotech") -> Path: + """An executable file standing in for QElectroTech. The policy checks + which file it is, never runs it.""" + bindir.mkdir(parents=True, exist_ok=True) + exe = bindir / name + exe.write_text("#!/bin/sh\nexit 0\n") + exe.chmod(0o755) + return exe + + def png(path: Path) -> None: """A real 64x32 PNG from the standard library, so no imaging dependency.""" import struct @@ -1767,6 +1777,8 @@ class PathPolicy(unittest.TestCase): self._saved = dict(os.environ) os.environ["QET_MCP_WORKSPACE"] = str(self.root) os.environ.pop("QET_MCP_ALLOW_ANY_PATH", None) + self.qet = fake_qet(Path(self.tmp.name) / "bin") + os.environ["QET_BINARY"] = str(self.qet) def tearDown(self): os.environ.clear() @@ -1800,7 +1812,7 @@ class PathPolicy(unittest.TestCase): def test_write_outside_the_workspace_is_refused(self): with self.assertRaisesRegex(ValueError, "outside the workspace"): m.enforce_path_policy("qet_export", { - "binary": "/usr/bin/qelectrotech", + "binary": str(self.qet), "project": str(self.root / "ok.qet"), "format": "pdf", "output": str(self.outside / "exfiltrated.pdf")}) @@ -1808,7 +1820,7 @@ class PathPolicy(unittest.TestCase): def test_existing_output_is_not_clobbered_without_overwrite(self): target = self.root / "existing.qet" target.write_text("precious") - args = {"binary": "/usr/bin/qelectrotech", "output": str(target), "title": "T"} + args = {"binary": str(self.qet), "output": str(target), "title": "T"} with self.assertRaisesRegex(ValueError, "already exists"): m.enforce_path_policy("qet_project_new", args) # ... and goes through once the caller says so explicitly @@ -1817,12 +1829,12 @@ class PathPolicy(unittest.TestCase): def test_new_output_needs_no_overwrite_flag(self): m.enforce_path_policy("qet_project_new", { - "binary": "/usr/bin/qelectrotech", + "binary": str(self.qet), "output": str(self.root / "brand_new.qet"), "title": "T"}) def test_operation_level_file_paths_are_checked(self): """add_image/add_pdf_page carry their own path, one level down.""" - base = {"binary": "/usr/bin/qelectrotech", + base = {"binary": str(self.qet), "project": str(self.root / "ok.qet"), "output": str(self.root / "out.qet")} outside_png = str(self.outside / "anything.png") @@ -1833,17 +1845,6 @@ class PathPolicy(unittest.TestCase): with self.assertRaisesRegex(ValueError, "outside the workspace"): m.enforce_path_policy("qet_edit", dict(base, operations=[op])) - def test_configuration_paths_are_exempt(self): - """binary and elements_dir are the operator's choice, not the model's. - - Both normally live in /usr or a build tree, so confining them would - reject the ordinary case while stopping nothing. - """ - m.enforce_path_policy("qet_query", { - "binary": "/usr/bin/qelectrotech", - "project": str(self.root / "ok.qet"), - "elements_dir": "/usr/share/qelectrotech/elements"}) - def test_several_roots_may_be_allowed(self): os.environ["QET_MCP_WORKSPACE"] = os.pathsep.join( [str(self.root), str(self.outside)]) @@ -1880,8 +1881,8 @@ class PathPolicy(unittest.TestCase): def test_every_data_path_argument_is_guarded(self): """The other direction: a tool whose schema takes a data path must be in the policy, or that path is read or written with no workspace - check at all -- and nothing fails. binary and elements_dir are - configuration, deliberately not confined (see the README).""" + check at all -- and nothing fails. binary and elements_dir have + their own rule (BinaryPolicy).""" pathish = {"path", "project", "before", "after", "output", "directory"} for t in m.TOOLS: with self.subTest(tool=t["name"]): @@ -1902,6 +1903,125 @@ class PathPolicy(unittest.TestCase): f"{name} has no {arg!r} argument to guard") +class BinaryPolicy(unittest.TestCase): + """The program the server launches is not the client's to choose (F063). + + "binary" used to be exempt from every check as "configuration", but it + is a per-call argument: any executable file it named was run, with the + client's own paths as arguments. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.root = base / "workspace" + self.root.mkdir() + (self.root / "ok.qet").write_text("") + self.qet = fake_qet(base / "bin") + self.other = fake_qet(base / "other") + self._saved = dict(os.environ) + os.environ["QET_MCP_WORKSPACE"] = str(self.root) + os.environ["QET_BINARY"] = str(self.qet) + # A PATH with no qelectrotech on it, so only what a test sets counts. + os.environ["PATH"] = str(base / "empty") + for var in ("QET_MCP_ALLOW_ANY_PATH", "QET_MCP_ALLOW_ANY_BINARY", + "QET_MCP_BINARIES", "QET_MCP_ELEMENTS"): + os.environ.pop(var, None) + + def tearDown(self): + os.environ.clear() + os.environ.update(self._saved) + self.tmp.cleanup() + + def call(self, **extra): + args = dict({"project": str(self.root / "ok.qet")}, **extra) + m.enforce_path_policy("qet_query", args) + return args + + def test_left_out_it_is_filled_in(self): + self.assertEqual(self.call()["binary"], str(self.qet.resolve())) + + def test_the_configured_one_is_accepted(self): + self.assertEqual(self.call(binary=str(self.qet))["binary"], str(self.qet.resolve())) + + def test_any_other_program_is_refused(self): + with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"): + self.call(binary=str(self.other)) + + def test_a_program_inside_the_workspace_is_refused_too(self): + """Being inside the workspace makes a file readable, not runnable.""" + planted = fake_qet(self.root, "run.sh") + with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"): + self.call(binary=str(planted)) + + def test_a_symlink_is_judged_by_its_target(self): + link = self.root / "qelectrotech" + link.symlink_to(self.other) + with self.assertRaisesRegex(ValueError, "not an allowed QElectroTech"): + self.call(binary=str(link)) + good = self.root / "also-qet" + good.symlink_to(self.qet) + self.call(binary=str(good)) + + def test_others_can_be_listed(self): + os.environ["QET_MCP_BINARIES"] = str(self.other) + self.assertEqual(self.call(binary=str(self.other))["binary"], str(self.other.resolve())) + + def test_the_check_can_be_switched_off(self): + os.environ["QET_MCP_ALLOW_ANY_BINARY"] = "1" + self.assertEqual(self.call(binary=str(self.other))["binary"], str(self.other)) + + def test_found_on_path(self): + del os.environ["QET_BINARY"] + os.environ["PATH"] = str(self.qet.parent) + self.assertEqual(self.call()["binary"], str(self.qet.resolve())) + + def test_none_found_says_what_to_set(self): + del os.environ["QET_BINARY"] + with self.assertRaisesRegex(ValueError, "set QET_BINARY"): + self.call() + + def test_every_tool_that_takes_binary_is_checked(self): + """A tool whose schema offers "binary" but that the policy skips + would run whatever it was given.""" + takes = {t["name"] for t in m.TOOLS + if "binary" in t["inputSchema"].get("properties", {})} + self.assertEqual(takes, m._LAUNCHES_QET) + for name in takes: + self.assertNotIn("binary", m._BY_NAME[name]["inputSchema"].get("required", []), + f"{name} still requires the client to name a binary") + + def test_elements_dir_outside_the_workspace_is_refused(self): + with self.assertRaisesRegex(ValueError, "'elements_dir' is outside"): + self.call(elements_dir=self.tmp.name) + + def test_elements_dir_can_be_listed(self): + coll = Path(self.tmp.name) / "collection" + coll.mkdir() + os.environ["QET_MCP_ELEMENTS"] = str(coll) + self.call(elements_dir=str(coll / "10_electric")) + + def test_an_install_finds_its_own_qet_and_collection(self): + """Installed as /share/qelectrotech/mcp/qet_mcp.py, the + server needs no configuration at all.""" + import importlib.util + prefix = Path(self.tmp.name) / "prefix" + mcp = prefix / "share" / "qelectrotech" / "mcp" + mcp.mkdir(parents=True) + (prefix / "share" / "qelectrotech" / "elements").mkdir() + shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py") + exe = fake_qet(prefix / "bin") + del os.environ["QET_BINARY"] + spec = importlib.util.spec_from_file_location("qet_mcp_installed", mcp / "qet_mcp.py") + inst = importlib.util.module_from_spec(spec) + spec.loader.exec_module(inst) + args = {"project": str(self.root / "ok.qet")} + inst.enforce_path_policy("qet_query", args) + self.assertEqual(args["binary"], str(exe.resolve())) + self.assertEqual(args["elements_dir"], + str((prefix / "share" / "qelectrotech" / "elements").resolve())) + + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. @@ -2036,6 +2156,24 @@ class PathPolicyOverStdio(unittest.TestCase): self.assertTrue(result.get("isError"), result) self.assertIn("outside the workspace", result["content"][0]["text"]) + def test_a_tool_call_cannot_choose_the_program_to_run(self): + """F063's reproduction: a script in the workspace, named as binary.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "a.qet").write_text('') + planted = fake_qet(root, "argv.py") + reply = self.rpc({"jsonrpc": "2.0", "id": 9, "method": "tools/call", + "params": {"name": "qet_export", + "arguments": {"binary": str(planted), + "project": str(root / "a.qet"), + "format": "pdf", + "output": str(root / "o.pdf")}}}, + {"QET_MCP_WORKSPACE": str(root), + "QET_BINARY": str(fake_qet(root / "bin"))}) + result = reply["result"] + self.assertTrue(result.get("isError"), result) + self.assertIn("not an allowed QElectroTech", result["content"][0]["text"]) + def test_the_same_call_succeeds_inside_the_workspace(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) / "ws" From ac952d3df6907ac78f2785be5fa04dc1b16c4b27 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 12:55:10 +1300 Subject: [PATCH 26/45] Install the MCP server with QElectroTech The MCP server (misc/qet-mcp) was only reachable from a source checkout. It now ships with every package, next to the program, and finds that QElectroTech and its element collection from where it sits: - make install (Linux distributions, snap, flatpak): /share/qelectrotech/mcp/qet_mcp.py, executable, with its README. - Windows installer: a new "AI assistant (MCP)" component (on by default, ~200 KB), installed to \mcp. The workflow stages files/mcp, so the portable folder and the MSI, which packs all of files/, carry it too. The server learns the Windows layout (/mcp beside /bin, whose program is QElectroTech.exe, and /elements), in addition to /share/qelectrotech/mcp. A copy saved anywhere else, even beside some bin/ folder, is not taken for an installation. The installer strings are given in all 29 installer languages: French translated, the others in English until translated, which is what NSIS would fall back to anyway but without its warning 6040 per language. Checked: make install into a scratch prefix, then from the installed script with nothing configured and no qelectrotech on PATH, an SVG export and a qet_edit placing a common:// element both succeeded. makensis 3.10 compiles the installer with 0 warnings before and after (removing the French description gives warning 6040), and the installer contains mcp/qet_mcp.py. Suite 274/274 none skipped; each layout check was removed in turn and a test failed. Snap/flatpak: installed by the same CMake rule; launching their QElectroTech from outside is untested. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows-build.yml | 5 ++- CMakeLists.txt | 4 +++ build-aux/windows/QET64.nsi | 8 +++++ build-aux/windows/lang_extra.nsh | 32 +++++++++++++++++++ build-aux/windows/lang_extra_fr.nsh | 2 ++ build-aux/windows/lang_extra_missing.nsh | 24 ++++++++++++++ misc/qet-mcp/README.md | 22 +++++++++++++ misc/qet-mcp/qet_mcp.py | 40 +++++++++++++++--------- misc/qet-mcp/test_qet_mcp.py | 37 ++++++++++++++++++++++ 9 files changed, 159 insertions(+), 15 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 2412235c5..0338cfffd 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -227,6 +227,9 @@ jobs: cp -r "$GITHUB_WORKSPACE/titleblocks" "$FILES/titleblocks" || true cp -r "$GITHUB_WORKSPACE/examples" "$FILES/examples" || true cp -r "$GITHUB_WORKSPACE/fonts" "$FILES/fonts" || true + # The MCP server finds bin/QElectroTech.exe and elements/ from mcp/. + mkdir -p "$FILES/mcp" + cp "$GITHUB_WORKSPACE/misc/qet-mcp/qet_mcp.py" "$GITHUB_WORKSPACE/misc/qet-mcp/README.md" "$FILES/mcp/" # --- Translations --- # Since PR #751 the .qm files are no longer tracked in git: lrelease @@ -305,7 +308,7 @@ jobs: && echo " OK : $f" \ || echo " MISSING: $f" done - for d in ico elements lang titleblocks fonts examples bin; do + for d in ico elements lang titleblocks fonts examples bin mcp; do [ -d "$FILES/$d" ] \ && echo " OK : $d/" \ || echo " MISSING: $d/" diff --git a/CMakeLists.txt b/CMakeLists.txt index a14f309f1..120f53a40 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -358,6 +358,10 @@ if (NOT MINGW) install(DIRECTORY elements DESTINATION share/qelectrotech) install(DIRECTORY examples DESTINATION share/qelectrotech) install(DIRECTORY titleblocks DESTINATION share/qelectrotech) + # The MCP server (misc/qet-mcp) finds this QElectroTech and its elements + # from here: /share/qelectrotech/mcp -> /bin. + install(PROGRAMS misc/qet-mcp/qet_mcp.py DESTINATION share/qelectrotech/mcp) + install(FILES misc/qet-mcp/README.md DESTINATION share/qelectrotech/mcp) install(FILES LICENSE ELEMENTS.LICENSE CREDIT README ChangeLog DESTINATION share/doc/qelectrotech) if(UNIX AND NOT APPLE) install(FILES misc/org.qelectrotech.qelectrotech.desktop DESTINATION share/applications) diff --git a/build-aux/windows/QET64.nsi b/build-aux/windows/QET64.nsi index cc1d81bd0..b1967b08f 100644 --- a/build-aux/windows/QET64.nsi +++ b/build-aux/windows/QET64.nsi @@ -274,6 +274,13 @@ Section "$(Fonts)" SEC05 File /nonfatal /r "./files/fonts" SectionEnd +; The MCP server lets an AI assistant open, check and edit drawings. It does +; nothing until a user sets up an assistant to start it. +Section "$(Mcp)" SEC06 + SetOutPath "$INSTDIR\mcp" + File /nonfatal "./files/mcp/*" +SectionEnd + ;-------------------------------- ; Component descriptions !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN @@ -282,6 +289,7 @@ SectionEnd !insertmacro MUI_DESCRIPTION_TEXT ${SEC03} $(var3) !insertmacro MUI_DESCRIPTION_TEXT ${SEC04} $(var4) !insertmacro MUI_DESCRIPTION_TEXT ${SEC05} $(var5) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC06} $(var6) !insertmacro MUI_FUNCTION_DESCRIPTION_END ;-------------------------------- diff --git a/build-aux/windows/lang_extra.nsh b/build-aux/windows/lang_extra.nsh index c76f128fd..2f51fafa6 100644 --- a/build-aux/windows/lang_extra.nsh +++ b/build-aux/windows/lang_extra.nsh @@ -19,6 +19,8 @@ LangString var3 ${LANG_ENGLISH} "Examples of cartridges" LangString var4 ${LANG_ENGLISH} "Examples of diagrams" LangString var5 ${LANG_ENGLISH} "Fonts" + LangString Mcp ${LANG_ENGLISH} "AI assistant (MCP)" + LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ENGLISH} "Uninstallation of the previous version failed.$\nPlease uninstall ${SOFT_NAME} manually before continuing." @@ -43,6 +45,8 @@ LangString var3 ${LANG_KOREAN} "표제란 예제" LangString var4 ${LANG_KOREAN} "도면 예제" LangString var5 ${LANG_KOREAN} "글꼴" + LangString Mcp ${LANG_KOREAN} "AI assistant (MCP)" + LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_KOREAN} "이전 버전을 제거하지 못했습니다.$\n계속하기 전에 ${SOFT_NAME}을(를) 수동으로 제거해 주세요." @@ -67,6 +71,8 @@ LangString var3 ${LANG_POLISH} "Przykłady tabliczek rysunkowych" LangString var4 ${LANG_POLISH} "Przykłady schematów" LangString var5 ${LANG_POLISH} "Czcionki" + LangString Mcp ${LANG_POLISH} "AI assistant (MCP)" + LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_POLISH} "Odinstalowanie poprzedniej wersji nie powiodło się.$\nPrzed kontynuowaniem odinstaluj ręcznie program ${SOFT_NAME}." @@ -91,6 +97,8 @@ LangString var3 ${LANG_GREEK} "Παραδείγματα πινακίδων" LangString var4 ${LANG_GREEK} "Παραδείγματα διαγραμμάτων" LangString var5 ${LANG_GREEK} "Γραμματοσειρές" + LangString Mcp ${LANG_GREEK} "AI assistant (MCP)" + LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_GREEK} "Η απεγκατάσταση της προηγούμενης έκδοσης απέτυχε.$\nΠαρακαλώ απεγκαταστήστε χειροκίνητα το ${SOFT_NAME} πριν συνεχίσετε." @@ -115,6 +123,8 @@ LangString var3 ${LANG_CZECH} "Příklady popisových polí" LangString var4 ${LANG_CZECH} "Příklady schémat" LangString var5 ${LANG_CZECH} "Písma" + LangString Mcp ${LANG_CZECH} "AI assistant (MCP)" + LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_CZECH} "Odinstalování předchozí verze se nezdařilo.$\nPřed pokračováním prosím odinstalujte ${SOFT_NAME} ručně." @@ -139,6 +149,8 @@ LangString var3 ${LANG_SPANISH} "Ejemplos de cartelas" LangString var4 ${LANG_SPANISH} "Ejemplos de esquemas" LangString var5 ${LANG_SPANISH} "Fuentes" + LangString Mcp ${LANG_SPANISH} "AI assistant (MCP)" + LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SPANISH} "La desinstalación de la versión anterior ha fallado.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -163,6 +175,8 @@ LangString var3 ${LANG_GERMAN} "Schriftfeld-Beispiele" LangString var4 ${LANG_GERMAN} "Schaltplan-Beispiele" LangString var5 ${LANG_GERMAN} "Schriften" + LangString Mcp ${LANG_GERMAN} "AI assistant (MCP)" + LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_GERMAN} "Die Deinstallation der vorherigen Version ist fehlgeschlagen.$\nBitte deinstallieren Sie ${SOFT_NAME} manuell, bevor Sie fortfahren." @@ -187,6 +201,8 @@ LangString var3 ${LANG_RUSSIAN} "Примеры штампов" LangString var4 ${LANG_RUSSIAN} "Примеры схем" LangString var5 ${LANG_RUSSIAN} "Шрифты" + LangString Mcp ${LANG_RUSSIAN} "AI assistant (MCP)" + LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_RUSSIAN} "Удаление предыдущей версии завершилось с ошибкой.$\nПожалуйста, удалите ${SOFT_NAME} вручную перед продолжением." @@ -211,6 +227,8 @@ LangString var3 ${LANG_ARABIC} "أمثلة على كتل العنوان" LangString var4 ${LANG_ARABIC} "أمثلة على المخططات" LangString var5 ${LANG_ARABIC} "الخطوط" + LangString Mcp ${LANG_ARABIC} "AI assistant (MCP)" + LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ARABIC} "فشل إلغاء تثبيت الإصدار السابق. يرجى إلغاء تثبيت ${SOFT_NAME} يدويًا قبل المتابعة." @@ -235,6 +253,8 @@ LangString var3 ${LANG_CATALAN} "Exemples de cartutxos" LangString var4 ${LANG_CATALAN} "Exemples d'esquemes" LangString var5 ${LANG_CATALAN} "Tipus de lletra" + LangString Mcp ${LANG_CATALAN} "AI assistant (MCP)" + LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_CATALAN} "La desinstal·lació de la versió anterior ha fallat.$\nSi us plau, desinstal·leu ${SOFT_NAME} manualment abans de continuar." @@ -259,6 +279,8 @@ LangString var3 ${LANG_ITALIAN} "Cartigli di esempio" LangString var4 ${LANG_ITALIAN} "Schemi di esempio" LangString var5 ${LANG_ITALIAN} "Caratteri" + LangString Mcp ${LANG_ITALIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ITALIAN} "La disinstallazione della versione precedente non è riuscita.$\nSi prega di disinstallare ${SOFT_NAME} manualmente prima di continuare." @@ -283,6 +305,8 @@ LangString var3 ${LANG_PORTUGUESE} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESE} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESE} "Fontes" + LangString Mcp ${LANG_PORTUGUESE} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_PORTUGUESE} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -307,6 +331,8 @@ LangString var3 ${LANG_ROMANIAN} "Exemple de cartușe" LangString var4 ${LANG_ROMANIAN} "Exemple de scheme" LangString var5 ${LANG_ROMANIAN} "Fonturi" + LangString Mcp ${LANG_ROMANIAN} "AI assistant (MCP)" + LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_ROMANIAN} "Dezinstalarea versiunii anterioare a eșuat.$\nVă rugăm să dezinstalați ${SOFT_NAME} manual înainte de a continua." @@ -331,6 +357,8 @@ LangString var3 ${LANG_CROATIAN} "Primjeri zaglavlja" LangString var4 ${LANG_CROATIAN} "Primjeri shema" LangString var5 ${LANG_CROATIAN} "Fontovi" + LangString Mcp ${LANG_CROATIAN} "AI assistant (MCP)" + LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_CROATIAN} "Deinstalacija prethodne verzije nije uspjela.$\nMolimo deinstalirajte ${SOFT_NAME} ručno prije nastavka." @@ -355,6 +383,8 @@ LangString var3 ${LANG_DUTCH} "Voorbeelden van titelblokken" LangString var4 ${LANG_DUTCH} "Voorbeelden van schema's" LangString var5 ${LANG_DUTCH} "Lettertypen" + LangString Mcp ${LANG_DUTCH} "AI assistant (MCP)" + LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_DUTCH} "Het verwijderen van de vorige versie is mislukt.$\nVerwijder ${SOFT_NAME} handmatig voordat u verdergaat." @@ -379,6 +409,8 @@ LangString var3 ${LANG_DANISH} "Titel blokke eksempler" LangString var4 ${LANG_DANISH} "Diagram eksempler" LangString var5 ${LANG_DANISH} "Skrifttyper" + LangString Mcp ${LANG_DANISH} "AI assistant (MCP)" + LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_DANISH} "Afinstallation af den tidligere version mislykkedes.$\nAfinstaller venligst ${SOFT_NAME} manuelt, inden du fortsætter." diff --git a/build-aux/windows/lang_extra_fr.nsh b/build-aux/windows/lang_extra_fr.nsh index 322322b7d..dfb527009 100644 --- a/build-aux/windows/lang_extra_fr.nsh +++ b/build-aux/windows/lang_extra_fr.nsh @@ -19,4 +19,6 @@ LangString var3 ${LANG_FRENCH} "Exemples de cartouches" LangString var4 ${LANG_FRENCH} "Exemples de schémas" LangString var5 ${LANG_FRENCH} "Polices" + LangString Mcp ${LANG_FRENCH} "Assistant IA (MCP)" + LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas (nécessite Python)" LangString uninstFailed ${LANG_FRENCH} "La désinstallation de la version précédente a échoué.$\nVeuillez désinstaller ${SOFT_NAME} manuellement avant de continuer." diff --git a/build-aux/windows/lang_extra_missing.nsh b/build-aux/windows/lang_extra_missing.nsh index 9313f6c90..a4a69bdd3 100644 --- a/build-aux/windows/lang_extra_missing.nsh +++ b/build-aux/windows/lang_extra_missing.nsh @@ -25,6 +25,8 @@ LangString var3 ${LANG_HUNGARIAN} "Rajzfejléc-példák" LangString var4 ${LANG_HUNGARIAN} "Kapcsolási rajz példák" LangString var5 ${LANG_HUNGARIAN} "Betűtípusok" + LangString Mcp ${LANG_HUNGARIAN} "AI assistant (MCP)" + LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_HUNGARIAN} "Az előző verzió eltávolítása nem sikerült.$\nKérjük, távolítsa el manuálisan a ${SOFT_NAME} programot, mielőtt folytatná." @@ -52,6 +54,8 @@ LangString var3 ${LANG_JAPANESE} "表題欄の例" LangString var4 ${LANG_JAPANESE} "回路図の例" LangString var5 ${LANG_JAPANESE} "フォント" + LangString Mcp ${LANG_JAPANESE} "AI assistant (MCP)" + LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_JAPANESE} "前のバージョンのアンインストールに失敗しました。$\n続行する前に、${SOFT_NAME} を手動でアンインストールしてください。" @@ -79,6 +83,8 @@ LangString var3 ${LANG_MONGOLIAN} "Гарчгийн блокын жишээнүүд" LangString var4 ${LANG_MONGOLIAN} "Схемийн жишээнүүд" LangString var5 ${LANG_MONGOLIAN} "Фонт" + LangString Mcp ${LANG_MONGOLIAN} "AI assistant (MCP)" + LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_MONGOLIAN} "Өмнөх хувилбарыг устгаж чадсангүй.$\nҮргэлжлүүлэхийн өмнө ${SOFT_NAME}-г гараар устгана уу." @@ -106,6 +112,8 @@ LangString var3 ${LANG_NORWEGIAN} "Eksempler på titelblokker" LangString var4 ${LANG_NORWEGIAN} "Eksempler på skjemaer" LangString var5 ${LANG_NORWEGIAN} "Skrifttyper" + LangString Mcp ${LANG_NORWEGIAN} "AI assistant (MCP)" + LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_NORWEGIAN} "Avinstallasjon av forrige versjon mislyktes.$\nVennligst avinstaller ${SOFT_NAME} manuelt før du fortsetter." @@ -143,6 +151,8 @@ LangString var3 ${LANG_PORTUGUESEBR} "Exemplos de legendas" LangString var4 ${LANG_PORTUGUESEBR} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESEBR} "Fontes" + LangString Mcp ${LANG_PORTUGUESEBR} "AI assistant (MCP)" + LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_PORTUGUESEBR} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -170,6 +180,8 @@ LangString var3 ${LANG_SERBIAN} "Примери заглавља" LangString var4 ${LANG_SERBIAN} "Примери шема" LangString var5 ${LANG_SERBIAN} "Фонтови" + LangString Mcp ${LANG_SERBIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SERBIAN} "Деинсталација претходне верзије није успела.$\nМолимо деинсталирајте ${SOFT_NAME} ручно пре наставка." @@ -197,6 +209,8 @@ LangString var3 ${LANG_SLOVAK} "Príklady popisových polí" LangString var4 ${LANG_SLOVAK} "Príklady schém" LangString var5 ${LANG_SLOVAK} "Písma" + LangString Mcp ${LANG_SLOVAK} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SLOVAK} "Odinštalovanie predchádzajúcej verzie zlyhalo.$\nPred pokračovaním odinštalujte ${SOFT_NAME} ručne." @@ -224,6 +238,8 @@ LangString var3 ${LANG_SLOVENIAN} "Primeri glav risb" LangString var4 ${LANG_SLOVENIAN} "Primeri shem" LangString var5 ${LANG_SLOVENIAN} "Pisave" + LangString Mcp ${LANG_SLOVENIAN} "AI assistant (MCP)" + LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SLOVENIAN} "Odstranitev prejšnje različice ni uspela.$\nPred nadaljevanjem ročno odstranite ${SOFT_NAME}." @@ -251,6 +267,8 @@ LangString var3 ${LANG_SWEDISH} "Exempel på ritningshuvuden" LangString var4 ${LANG_SWEDISH} "Exempel på scheman" LangString var5 ${LANG_SWEDISH} "Teckensnitt" + LangString Mcp ${LANG_SWEDISH} "AI assistant (MCP)" + LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SWEDISH} "Avinstallationen av den föregående versionen misslyckades.$\nAvinstallera ${SOFT_NAME} manuellt innan du fortsätter." @@ -278,6 +296,8 @@ LangString var3 ${LANG_TURKISH} "Başlık bloğu örnekleri" LangString var4 ${LANG_TURKISH} "Şema örnekleri" LangString var5 ${LANG_TURKISH} "Yazı Tipleri" + LangString Mcp ${LANG_TURKISH} "AI assistant (MCP)" + LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_TURKISH} "Önceki sürümün kaldırılması başarısız oldu.$\nDevam etmeden önce lütfen ${SOFT_NAME}'i manuel olarak kaldırın." @@ -305,6 +325,8 @@ LangString var3 ${LANG_UKRAINIAN} "Приклади штампів" LangString var4 ${LANG_UKRAINIAN} "Приклади схем" LangString var5 ${LANG_UKRAINIAN} "Шрифти" + LangString Mcp ${LANG_UKRAINIAN} "AI assistant (MCP)" + LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_UKRAINIAN} "Видалення попередньої версії завершилося помилкою.$\nБудь ласка, видаліть ${SOFT_NAME} вручну перед продовженням." @@ -332,4 +354,6 @@ LangString var3 ${LANG_SIMPCHINESE} "标题栏示例" LangString var4 ${LANG_SIMPCHINESE} "图纸示例" LangString var5 ${LANG_SIMPCHINESE} "字体" + LangString Mcp ${LANG_SIMPCHINESE} "AI assistant (MCP)" + LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" LangString uninstFailed ${LANG_SIMPCHINESE} "卸载旧版本失败。$\n请在继续之前手动卸载 ${SOFT_NAME}。" diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index f0640ea4f..3c082ace9 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -82,6 +82,28 @@ Register it with an MCP client, for example: QElectroTech (as `/share/qelectrotech/mcp/qet_mcp.py`, which also finds the installed element collection). +## Installed with QElectroTech + +QElectroTech's packages install the server next to the program, and from +there it finds that QElectroTech and its element collection by itself: + +| Package | Server | Finds | +|---|---|---| +| `make install`, Linux distributions | `/share/qelectrotech/mcp/qet_mcp.py` | `/bin/qelectrotech`, `/share/qelectrotech/elements` | +| Windows installer, MSI, portable folder | `\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `\bin\QElectroTech.exe`, `\elements` | + +So a client configuration needs only the path to the server and the +workspace, for example on Windows: + +```json +"args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"] +``` + +Snap and flatpak install it too. Their QElectroTech is built to run inside +the package's sandbox, and starting it from the server has not been tested: +the tools that read a file work, exports and edits may not. If they fail, +point `QET_BINARY` at a QElectroTech installed another way. + ## Using it from the Claude app A web chat in a browser cannot start a program on your computer, so it diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 485a53bf8..ce11cd467 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -3189,12 +3189,24 @@ def _env_paths(name: str) -> list: return out -def _installed_prefix() -> Path | None: - """The install prefix when this script is /share/qelectrotech/mcp/.""" +def _installation() -> tuple | None: + """(program directory, element collection) of the QElectroTech this + script was installed with, or None when it runs from anywhere else. + + Two layouts, both put there by QElectroTech's own packaging: + /share/qelectrotech/mcp/ -> /bin, /share/qelectrotech/elements + (make install: Linux, snap, flatpak, macOS) + /mcp/ -> /bin, /elements + (the Windows installers and portable folder) + """ here = Path(__file__).resolve().parent - if here.name == "mcp" and here.parent.name == "qelectrotech" \ - and here.parent.parent.name == "share": - return here.parent.parent.parent + if here.name != "mcp": + return None + if here.parent.name == "qelectrotech" and here.parent.parent.name == "share": + prefix = here.parent.parent.parent + return prefix / "bin", here.parent / "elements" + if (here.parent / "bin").is_dir(): + return here.parent / "bin", here.parent / "elements" return None @@ -3208,10 +3220,12 @@ def resolve_binary() -> Path | None: env = os.environ.get("QET_BINARY", "").strip() if env: return Path(env).expanduser().resolve() - prefix = _installed_prefix() - if prefix is not None: - for name in ("qelectrotech", "qelectrotech.exe"): - cand = prefix / "bin" / name + install = _installation() + if install is not None: + # The Windows build names it QElectroTech.exe; only a case-sensitive + # file system tells the spellings apart. + for name in ("qelectrotech", "qelectrotech.exe", "QElectroTech.exe"): + cand = install[0] / name if cand.is_file(): return cand.resolve() found = shutil.which("qelectrotech") @@ -3220,11 +3234,9 @@ def resolve_binary() -> Path | None: def default_elements_dir() -> Path | None: """The element collection of the install this script ships in, if any.""" - prefix = _installed_prefix() - if prefix is not None: - coll = prefix / "share" / "qelectrotech" / "elements" - if coll.is_dir(): - return coll.resolve() + install = _installation() + if install is not None and install[1].is_dir(): + return install[1].resolve() return None diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 6c98569d8..875da3324 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2021,6 +2021,43 @@ class BinaryPolicy(unittest.TestCase): self.assertEqual(args["elements_dir"], str((prefix / "share" / "qelectrotech" / "elements").resolve())) + def installed_at(self, root: Path, mcp: Path): + """Import a copy of the server placed at mcp/qet_mcp.py.""" + import importlib.util + mcp.mkdir(parents=True) + shutil.copy2(HERE / "qet_mcp.py", mcp / "qet_mcp.py") + spec = importlib.util.spec_from_file_location(f"qet_mcp_{root.name}", mcp / "qet_mcp.py") + inst = importlib.util.module_from_spec(spec) + spec.loader.exec_module(inst) + return inst + + def test_the_windows_layout_is_found_too(self): + """The Windows installers and the portable folder put the server in + /mcp, the program in /bin as QElectroTech.exe, and the + collection in /elements.""" + root = Path(self.tmp.name) / "QElectroTech" + (root / "elements").mkdir(parents=True) + exe = fake_qet(root / "bin", "QElectroTech.exe") + inst = self.installed_at(root, root / "mcp") + del os.environ["QET_BINARY"] + args = {"project": str(self.root / "ok.qet")} + inst.enforce_path_policy("qet_query", args) + self.assertEqual(args["binary"], str(exe.resolve())) + self.assertEqual(args["elements_dir"], str((root / "elements").resolve())) + + def test_a_copy_saved_anywhere_is_not_an_install(self): + """A downloaded copy sitting beside some bin/ folder must not go + looking for QElectroTech there.""" + home = Path(self.tmp.name) / "home" + fake_qet(home / "bin") + inst = self.installed_at(home, home / "Downloads") + self.assertIsNone(inst._installation()) + + def test_a_folder_named_mcp_is_not_an_install(self): + """Without a bin/ beside it, an mcp/ folder is just a folder.""" + inst = self.installed_at(Path(self.tmp.name) / "x", Path(self.tmp.name) / "x" / "mcp") + self.assertIsNone(inst._installation()) + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. From 6b9c5189b1695928f2a9f797972a3d9e05af1301 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 13:00:30 +1300 Subject: [PATCH 27/45] Offer Python with the Windows installer, for the MCP server Most Windows users have no Python, which the MCP server needs. The installer now offers "Python for the AI assistant", unticked by default: the official embeddable package from python.org (3.14.7, ~12 MB, no registry, removed with QElectroTech), in \mcp\python. The portable folder and the MSI, which pack all of files/, carry it. The workflow downloads it pinned by version and by python.org's own sha256 for the file (checked against python.org's download API), and fails the build on a mismatch. The script stays plain text beside it. Checked under Wine (64-bit, qet-wine-smoke image), on an installer built with makensis 3.10 (0 warnings, strings in all 29 languages): - a silent default install puts mcp\qet_mcp.py in place and no Python; the same installer with the section ticked by default installs it, so the check tells the two apart; - the installed Python runs the installed server: 15 tools listed, qet_project_info on an example answers as on Linux, and the server finds bin\QElectroTech.exe and elements\ by itself. Not checked: a real Windows machine; the CI download step (fork run). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows-build.yml | 14 ++++++ build-aux/windows/QET64.nsi | 9 ++++ build-aux/windows/lang_extra.nsh | 64 ++++++++++++++++++------ build-aux/windows/lang_extra_fr.nsh | 4 +- build-aux/windows/lang_extra_missing.nsh | 48 +++++++++++++----- misc/qet-mcp/README.md | 6 ++- 6 files changed, 115 insertions(+), 30 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 0338cfffd..b7ef44a9d 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -230,6 +230,20 @@ jobs: # The MCP server finds bin/QElectroTech.exe and elements/ from mcp/. mkdir -p "$FILES/mcp" cp "$GITHUB_WORKSPACE/misc/qet-mcp/qet_mcp.py" "$GITHUB_WORKSPACE/misc/qet-mcp/README.md" "$FILES/mcp/" + # Python for it, so Windows users need not install one: the official + # embeddable package, pinned by version and checksum (python.org's + # own sha256 for this file). The installer offers it unticked; the + # portable folder and the MSI carry it. + PY_EMBED_VERSION=3.14.7 + PY_EMBED_SHA256=d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15 + PY_ZIP="$GITHUB_WORKSPACE/python-embed.zip" + curl -fsSL -o "$PY_ZIP" \ + "https://www.python.org/ftp/python/$PY_EMBED_VERSION/python-$PY_EMBED_VERSION-embed-amd64.zip" + echo "$PY_EMBED_SHA256 $PY_ZIP" | sha256sum -c - \ + || { echo "ERROR: embeddable Python checksum mismatch"; exit 1; } + python3 -m zipfile -e "$PY_ZIP" "$FILES/mcp/python" + [ -f "$FILES/mcp/python/python.exe" ] \ + || { echo "ERROR: python.exe missing from the embeddable package"; exit 1; } # --- Translations --- # Since PR #751 the .qm files are no longer tracked in git: lrelease diff --git a/build-aux/windows/QET64.nsi b/build-aux/windows/QET64.nsi index b1967b08f..fe1522457 100644 --- a/build-aux/windows/QET64.nsi +++ b/build-aux/windows/QET64.nsi @@ -281,6 +281,14 @@ Section "$(Mcp)" SEC06 File /nonfatal "./files/mcp/*" SectionEnd +; Python for the MCP server, from python.org (about 12 MB). Unticked by +; default: only someone setting up an AI assistant needs it, and then only +; without a Python of their own. Uninstall removes it with $INSTDIR. +Section /o "$(McpPython)" SEC07 + SetOutPath "$INSTDIR\mcp\python" + File /nonfatal /r "./files/mcp/python/*" +SectionEnd + ;-------------------------------- ; Component descriptions !insertmacro MUI_FUNCTION_DESCRIPTION_BEGIN @@ -290,6 +298,7 @@ SectionEnd !insertmacro MUI_DESCRIPTION_TEXT ${SEC04} $(var4) !insertmacro MUI_DESCRIPTION_TEXT ${SEC05} $(var5) !insertmacro MUI_DESCRIPTION_TEXT ${SEC06} $(var6) + !insertmacro MUI_DESCRIPTION_TEXT ${SEC07} $(var7) !insertmacro MUI_FUNCTION_DESCRIPTION_END ;-------------------------------- diff --git a/build-aux/windows/lang_extra.nsh b/build-aux/windows/lang_extra.nsh index 2f51fafa6..2c6dcb45c 100644 --- a/build-aux/windows/lang_extra.nsh +++ b/build-aux/windows/lang_extra.nsh @@ -20,7 +20,9 @@ LangString var4 ${LANG_ENGLISH} "Examples of diagrams" LangString var5 ${LANG_ENGLISH} "Fonts" LangString Mcp ${LANG_ENGLISH} "AI assistant (MCP)" - LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ENGLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ENGLISH} "Python for the AI assistant" + LangString var7 ${LANG_ENGLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ENGLISH} "Uninstallation of the previous version failed.$\nPlease uninstall ${SOFT_NAME} manually before continuing." @@ -46,7 +48,9 @@ LangString var4 ${LANG_KOREAN} "도면 예제" LangString var5 ${LANG_KOREAN} "글꼴" LangString Mcp ${LANG_KOREAN} "AI assistant (MCP)" - LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_KOREAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_KOREAN} "Python for the AI assistant" + LangString var7 ${LANG_KOREAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_KOREAN} "이전 버전을 제거하지 못했습니다.$\n계속하기 전에 ${SOFT_NAME}을(를) 수동으로 제거해 주세요." @@ -72,7 +76,9 @@ LangString var4 ${LANG_POLISH} "Przykłady schematów" LangString var5 ${LANG_POLISH} "Czcionki" LangString Mcp ${LANG_POLISH} "AI assistant (MCP)" - LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_POLISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_POLISH} "Python for the AI assistant" + LangString var7 ${LANG_POLISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_POLISH} "Odinstalowanie poprzedniej wersji nie powiodło się.$\nPrzed kontynuowaniem odinstaluj ręcznie program ${SOFT_NAME}." @@ -98,7 +104,9 @@ LangString var4 ${LANG_GREEK} "Παραδείγματα διαγραμμάτων" LangString var5 ${LANG_GREEK} "Γραμματοσειρές" LangString Mcp ${LANG_GREEK} "AI assistant (MCP)" - LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_GREEK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GREEK} "Python for the AI assistant" + LangString var7 ${LANG_GREEK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GREEK} "Η απεγκατάσταση της προηγούμενης έκδοσης απέτυχε.$\nΠαρακαλώ απεγκαταστήστε χειροκίνητα το ${SOFT_NAME} πριν συνεχίσετε." @@ -124,7 +132,9 @@ LangString var4 ${LANG_CZECH} "Příklady schémat" LangString var5 ${LANG_CZECH} "Písma" LangString Mcp ${LANG_CZECH} "AI assistant (MCP)" - LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_CZECH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CZECH} "Python for the AI assistant" + LangString var7 ${LANG_CZECH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CZECH} "Odinstalování předchozí verze se nezdařilo.$\nPřed pokračováním prosím odinstalujte ${SOFT_NAME} ručně." @@ -150,7 +160,9 @@ LangString var4 ${LANG_SPANISH} "Ejemplos de esquemas" LangString var5 ${LANG_SPANISH} "Fuentes" LangString Mcp ${LANG_SPANISH} "AI assistant (MCP)" - LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SPANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SPANISH} "Python for the AI assistant" + LangString var7 ${LANG_SPANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SPANISH} "La desinstalación de la versión anterior ha fallado.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -176,7 +188,9 @@ LangString var4 ${LANG_GERMAN} "Schaltplan-Beispiele" LangString var5 ${LANG_GERMAN} "Schriften" LangString Mcp ${LANG_GERMAN} "AI assistant (MCP)" - LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_GERMAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_GERMAN} "Python for the AI assistant" + LangString var7 ${LANG_GERMAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_GERMAN} "Die Deinstallation der vorherigen Version ist fehlgeschlagen.$\nBitte deinstallieren Sie ${SOFT_NAME} manuell, bevor Sie fortfahren." @@ -202,7 +216,9 @@ LangString var4 ${LANG_RUSSIAN} "Примеры схем" LangString var5 ${LANG_RUSSIAN} "Шрифты" LangString Mcp ${LANG_RUSSIAN} "AI assistant (MCP)" - LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_RUSSIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_RUSSIAN} "Python for the AI assistant" + LangString var7 ${LANG_RUSSIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_RUSSIAN} "Удаление предыдущей версии завершилось с ошибкой.$\nПожалуйста, удалите ${SOFT_NAME} вручную перед продолжением." @@ -228,7 +244,9 @@ LangString var4 ${LANG_ARABIC} "أمثلة على المخططات" LangString var5 ${LANG_ARABIC} "الخطوط" LangString Mcp ${LANG_ARABIC} "AI assistant (MCP)" - LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ARABIC} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ARABIC} "Python for the AI assistant" + LangString var7 ${LANG_ARABIC} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ARABIC} "فشل إلغاء تثبيت الإصدار السابق. يرجى إلغاء تثبيت ${SOFT_NAME} يدويًا قبل المتابعة." @@ -254,7 +272,9 @@ LangString var4 ${LANG_CATALAN} "Exemples d'esquemes" LangString var5 ${LANG_CATALAN} "Tipus de lletra" LangString Mcp ${LANG_CATALAN} "AI assistant (MCP)" - LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_CATALAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CATALAN} "Python for the AI assistant" + LangString var7 ${LANG_CATALAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CATALAN} "La desinstal·lació de la versió anterior ha fallat.$\nSi us plau, desinstal·leu ${SOFT_NAME} manualment abans de continuar." @@ -280,7 +300,9 @@ LangString var4 ${LANG_ITALIAN} "Schemi di esempio" LangString var5 ${LANG_ITALIAN} "Caratteri" LangString Mcp ${LANG_ITALIAN} "AI assistant (MCP)" - LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ITALIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ITALIAN} "Python for the AI assistant" + LangString var7 ${LANG_ITALIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ITALIAN} "La disinstallazione della versione precedente non è riuscita.$\nSi prega di disinstallare ${SOFT_NAME} manualmente prima di continuare." @@ -306,7 +328,9 @@ LangString var4 ${LANG_PORTUGUESE} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESE} "Fontes" LangString Mcp ${LANG_PORTUGUESE} "AI assistant (MCP)" - LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_PORTUGUESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESE} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESE} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -332,7 +356,9 @@ LangString var4 ${LANG_ROMANIAN} "Exemple de scheme" LangString var5 ${LANG_ROMANIAN} "Fonturi" LangString Mcp ${LANG_ROMANIAN} "AI assistant (MCP)" - LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_ROMANIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_ROMANIAN} "Python for the AI assistant" + LangString var7 ${LANG_ROMANIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_ROMANIAN} "Dezinstalarea versiunii anterioare a eșuat.$\nVă rugăm să dezinstalați ${SOFT_NAME} manual înainte de a continua." @@ -358,7 +384,9 @@ LangString var4 ${LANG_CROATIAN} "Primjeri shema" LangString var5 ${LANG_CROATIAN} "Fontovi" LangString Mcp ${LANG_CROATIAN} "AI assistant (MCP)" - LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_CROATIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_CROATIAN} "Python for the AI assistant" + LangString var7 ${LANG_CROATIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_CROATIAN} "Deinstalacija prethodne verzije nije uspjela.$\nMolimo deinstalirajte ${SOFT_NAME} ručno prije nastavka." @@ -384,7 +412,9 @@ LangString var4 ${LANG_DUTCH} "Voorbeelden van schema's" LangString var5 ${LANG_DUTCH} "Lettertypen" LangString Mcp ${LANG_DUTCH} "AI assistant (MCP)" - LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_DUTCH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DUTCH} "Python for the AI assistant" + LangString var7 ${LANG_DUTCH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DUTCH} "Het verwijderen van de vorige versie is mislukt.$\nVerwijder ${SOFT_NAME} handmatig voordat u verdergaat." @@ -410,7 +440,9 @@ LangString var4 ${LANG_DANISH} "Diagram eksempler" LangString var5 ${LANG_DANISH} "Skrifttyper" LangString Mcp ${LANG_DANISH} "AI assistant (MCP)" - LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_DANISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_DANISH} "Python for the AI assistant" + LangString var7 ${LANG_DANISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_DANISH} "Afinstallation af den tidligere version mislykkedes.$\nAfinstaller venligst ${SOFT_NAME} manuelt, inden du fortsætter." diff --git a/build-aux/windows/lang_extra_fr.nsh b/build-aux/windows/lang_extra_fr.nsh index dfb527009..bcfd9988b 100644 --- a/build-aux/windows/lang_extra_fr.nsh +++ b/build-aux/windows/lang_extra_fr.nsh @@ -20,5 +20,7 @@ LangString var4 ${LANG_FRENCH} "Exemples de schémas" LangString var5 ${LANG_FRENCH} "Polices" LangString Mcp ${LANG_FRENCH} "Assistant IA (MCP)" - LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas (nécessite Python)" + LangString var6 ${LANG_FRENCH} "Permet à un assistant IA d'ouvrir, vérifier et modifier vos schémas. Nécessite Python : le vôtre, ou celui proposé ci-dessous" + LangString McpPython ${LANG_FRENCH} "Python pour l'assistant IA" + LangString var7 ${LANG_FRENCH} "Python de python.org, utilisé uniquement par le composant Assistant IA. Inutile si Python est déjà installé (environ 12 Mo)" LangString uninstFailed ${LANG_FRENCH} "La désinstallation de la version précédente a échoué.$\nVeuillez désinstaller ${SOFT_NAME} manuellement avant de continuer." diff --git a/build-aux/windows/lang_extra_missing.nsh b/build-aux/windows/lang_extra_missing.nsh index a4a69bdd3..afae58e03 100644 --- a/build-aux/windows/lang_extra_missing.nsh +++ b/build-aux/windows/lang_extra_missing.nsh @@ -26,7 +26,9 @@ LangString var4 ${LANG_HUNGARIAN} "Kapcsolási rajz példák" LangString var5 ${LANG_HUNGARIAN} "Betűtípusok" LangString Mcp ${LANG_HUNGARIAN} "AI assistant (MCP)" - LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_HUNGARIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_HUNGARIAN} "Python for the AI assistant" + LangString var7 ${LANG_HUNGARIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_HUNGARIAN} "Az előző verzió eltávolítása nem sikerült.$\nKérjük, távolítsa el manuálisan a ${SOFT_NAME} programot, mielőtt folytatná." @@ -55,7 +57,9 @@ LangString var4 ${LANG_JAPANESE} "回路図の例" LangString var5 ${LANG_JAPANESE} "フォント" LangString Mcp ${LANG_JAPANESE} "AI assistant (MCP)" - LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_JAPANESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_JAPANESE} "Python for the AI assistant" + LangString var7 ${LANG_JAPANESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_JAPANESE} "前のバージョンのアンインストールに失敗しました。$\n続行する前に、${SOFT_NAME} を手動でアンインストールしてください。" @@ -84,7 +88,9 @@ LangString var4 ${LANG_MONGOLIAN} "Схемийн жишээнүүд" LangString var5 ${LANG_MONGOLIAN} "Фонт" LangString Mcp ${LANG_MONGOLIAN} "AI assistant (MCP)" - LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_MONGOLIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_MONGOLIAN} "Python for the AI assistant" + LangString var7 ${LANG_MONGOLIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_MONGOLIAN} "Өмнөх хувилбарыг устгаж чадсангүй.$\nҮргэлжлүүлэхийн өмнө ${SOFT_NAME}-г гараар устгана уу." @@ -113,7 +119,9 @@ LangString var4 ${LANG_NORWEGIAN} "Eksempler på skjemaer" LangString var5 ${LANG_NORWEGIAN} "Skrifttyper" LangString Mcp ${LANG_NORWEGIAN} "AI assistant (MCP)" - LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_NORWEGIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_NORWEGIAN} "Python for the AI assistant" + LangString var7 ${LANG_NORWEGIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_NORWEGIAN} "Avinstallasjon av forrige versjon mislyktes.$\nVennligst avinstaller ${SOFT_NAME} manuelt før du fortsetter." @@ -152,7 +160,9 @@ LangString var4 ${LANG_PORTUGUESEBR} "Exemplos de esquemas" LangString var5 ${LANG_PORTUGUESEBR} "Fontes" LangString Mcp ${LANG_PORTUGUESEBR} "AI assistant (MCP)" - LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_PORTUGUESEBR} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_PORTUGUESEBR} "Python for the AI assistant" + LangString var7 ${LANG_PORTUGUESEBR} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_PORTUGUESEBR} "A desinstalação da versão anterior falhou.$\nPor favor, desinstale ${SOFT_NAME} manualmente antes de continuar." @@ -181,7 +191,9 @@ LangString var4 ${LANG_SERBIAN} "Примери шема" LangString var5 ${LANG_SERBIAN} "Фонтови" LangString Mcp ${LANG_SERBIAN} "AI assistant (MCP)" - LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SERBIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SERBIAN} "Python for the AI assistant" + LangString var7 ${LANG_SERBIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SERBIAN} "Деинсталација претходне верзије није успела.$\nМолимо деинсталирајте ${SOFT_NAME} ручно пре наставка." @@ -210,7 +222,9 @@ LangString var4 ${LANG_SLOVAK} "Príklady schém" LangString var5 ${LANG_SLOVAK} "Písma" LangString Mcp ${LANG_SLOVAK} "AI assistant (MCP)" - LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SLOVAK} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVAK} "Python for the AI assistant" + LangString var7 ${LANG_SLOVAK} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVAK} "Odinštalovanie predchádzajúcej verzie zlyhalo.$\nPred pokračovaním odinštalujte ${SOFT_NAME} ručne." @@ -239,7 +253,9 @@ LangString var4 ${LANG_SLOVENIAN} "Primeri shem" LangString var5 ${LANG_SLOVENIAN} "Pisave" LangString Mcp ${LANG_SLOVENIAN} "AI assistant (MCP)" - LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SLOVENIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SLOVENIAN} "Python for the AI assistant" + LangString var7 ${LANG_SLOVENIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SLOVENIAN} "Odstranitev prejšnje različice ni uspela.$\nPred nadaljevanjem ročno odstranite ${SOFT_NAME}." @@ -268,7 +284,9 @@ LangString var4 ${LANG_SWEDISH} "Exempel på scheman" LangString var5 ${LANG_SWEDISH} "Teckensnitt" LangString Mcp ${LANG_SWEDISH} "AI assistant (MCP)" - LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SWEDISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SWEDISH} "Python for the AI assistant" + LangString var7 ${LANG_SWEDISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SWEDISH} "Avinstallationen av den föregående versionen misslyckades.$\nAvinstallera ${SOFT_NAME} manuellt innan du fortsätter." @@ -297,7 +315,9 @@ LangString var4 ${LANG_TURKISH} "Şema örnekleri" LangString var5 ${LANG_TURKISH} "Yazı Tipleri" LangString Mcp ${LANG_TURKISH} "AI assistant (MCP)" - LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_TURKISH} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_TURKISH} "Python for the AI assistant" + LangString var7 ${LANG_TURKISH} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_TURKISH} "Önceki sürümün kaldırılması başarısız oldu.$\nDevam etmeden önce lütfen ${SOFT_NAME}'i manuel olarak kaldırın." @@ -326,7 +346,9 @@ LangString var4 ${LANG_UKRAINIAN} "Приклади схем" LangString var5 ${LANG_UKRAINIAN} "Шрифти" LangString Mcp ${LANG_UKRAINIAN} "AI assistant (MCP)" - LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_UKRAINIAN} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_UKRAINIAN} "Python for the AI assistant" + LangString var7 ${LANG_UKRAINIAN} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_UKRAINIAN} "Видалення попередньої версії завершилося помилкою.$\nБудь ласка, видаліть ${SOFT_NAME} вручну перед продовженням." @@ -355,5 +377,7 @@ LangString var4 ${LANG_SIMPCHINESE} "图纸示例" LangString var5 ${LANG_SIMPCHINESE} "字体" LangString Mcp ${LANG_SIMPCHINESE} "AI assistant (MCP)" - LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings (needs Python)" + LangString var6 ${LANG_SIMPCHINESE} "Lets an AI assistant open, check and edit your drawings. Needs Python: your own, or the one offered below" + LangString McpPython ${LANG_SIMPCHINESE} "Python for the AI assistant" + LangString var7 ${LANG_SIMPCHINESE} "Python from python.org, used only by the AI assistant component. Not needed if Python is already installed (about 12 MB)" LangString uninstFailed ${LANG_SIMPCHINESE} "卸载旧版本失败。$\n请在继续之前手动卸载 ${SOFT_NAME}。" diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 3c082ace9..391956b4e 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -93,9 +93,13 @@ there it finds that QElectroTech and its element collection by itself: | Windows installer, MSI, portable folder | `\mcp\qet_mcp.py` (the "AI assistant (MCP)" component) | `\bin\QElectroTech.exe`, `\elements` | So a client configuration needs only the path to the server and the -workspace, for example on Windows: +workspace. On Windows, the installer also offers **Python for the AI +assistant** (unticked by default; always in the portable folder and the +MSI): Python from python.org in `\mcp\python`, for anyone without +a Python of their own. Then: ```json +"command": "C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe", "args": ["C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"] ``` From c76ef10e0b1da93add02447443be02e1d0a7a53b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 13:29:50 +1300 Subject: [PATCH 28/45] Add Help > Connect an AI assistant A dialog showing the configuration an AI assistant needs to start QElectroTech's MCP server (misc/qet-mcp), with this installation's paths filled in: pick the assistant (Claude Desktop, Claude Code, GitHub Copilot in VS Code, Cursor, Gemini CLI, Codex CLI, LM Studio), the drawings folder the assistant may use, and whether it may edit (off by default). It says where the text goes, warns that text inside a project from someone else can steer an assistant, and copies the text. It writes nothing and starts nothing. AiAssistantSetup finds the server beside the running program, in the two places the packages put it (/mcp beside /bin on Windows, /share/qelectrotech/mcp beside /bin otherwise), and the Python the Windows installer can add. When there is none, the dialog says the server is not installed with this version and links the guide. The configuration is written with QJsonDocument, so Windows paths are escaped correctly; Codex CLI gets TOML. tst_aiassistantsetup (9 cases): both layouts, no server, the bundled Python on Windows only, valid JSON for every JSON client with paths that parse back unchanged, the "servers" key for VS Code, "type" only where wanted, editing off unless allowed, escaped TOML. Each of 8 rules was removed in turn and the test failed. ctest 32/32. Checked in the GUI (Xvfb): Help menu opens it; from a build tree it reports the server missing and disables Copy; from an install layout it fills the paths, and the text Copy put on the clipboard, used unchanged as a Claude Code configuration, ran a real qet_project_info call. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 4 + sources/qetmainwindow.cpp | 9 ++ sources/qetmainwindow.h | 1 + sources/ui/aiassistantdialog.cpp | 198 ++++++++++++++++++++++++++ sources/ui/aiassistantdialog.h | 61 ++++++++ sources/ui/aiassistantsetup.cpp | 141 ++++++++++++++++++ sources/ui/aiassistantsetup.h | 64 +++++++++ tests/qttest/CMakeLists.txt | 10 ++ tests/qttest/tst_aiassistantsetup.cpp | 149 +++++++++++++++++++ 9 files changed, 637 insertions(+) create mode 100644 sources/ui/aiassistantdialog.cpp create mode 100644 sources/ui/aiassistantdialog.h create mode 100644 sources/ui/aiassistantsetup.cpp create mode 100644 sources/ui/aiassistantsetup.h create mode 100644 tests/qttest/tst_aiassistantsetup.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 5b3f13eb7..d57817384 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -714,6 +714,10 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/aboutqetdialog.cpp ${QET_DIR}/sources/ui/aboutqetdialog.h + ${QET_DIR}/sources/ui/aiassistantdialog.cpp + ${QET_DIR}/sources/ui/aiassistantdialog.h + ${QET_DIR}/sources/ui/aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.h ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.cpp ${QET_DIR}/sources/ui/abstractelementpropertieseditorwidget.h ${QET_DIR}/sources/ui/alignmenttextdialog.cpp diff --git a/sources/qetmainwindow.cpp b/sources/qetmainwindow.cpp index b3553d9d6..1436f2668 100644 --- a/sources/qetmainwindow.cpp +++ b/sources/qetmainwindow.cpp @@ -26,6 +26,7 @@ #include "qetmainwindow.h" #include "qeticons.h" +#include "ui/aiassistantdialog.h" #include "shortcutmanager.h" #include "qetapp.h" #include "qetdiagrameditor.h" @@ -111,6 +112,13 @@ void QETMainWindow::initCommonActions() ShortcutManager::instance().registerAction(manual_online_, "mainwindow.manual_online", tr("Général"), Qt::Key_F1); + connect_ai_ = new QAction(tr("Connecter un assistant IA..."), this); + connect_ai_ -> setStatusTip(tr("Affiche la configuration qui permet à un assistant IA d'utiliser QElectroTech", "status bar tip")); + connect(connect_ai_, &QAction::triggered, this, [this]() { + AiAssistantDialog dialog(this); + dialog.exec(); + }); + youtube_ = new QAction(QET::Icons::QETVideo, tr("Chaine Youtube"), this); youtube_ -> setStatusTip(tr("Lance le navigateur par défaut vers la chaine Youtube de QElectroTech", "status bar tip")); @@ -170,6 +178,7 @@ void QETMainWindow::initCommonMenus() help_menu_ -> addAction(whatsthis_action_); help_menu_ -> addSeparator(); help_menu_ -> addAction(manual_online_); + help_menu_ -> addAction(connect_ai_); help_menu_ -> addAction(youtube_); help_menu_ -> addAction(upgrade_); help_menu_ -> addAction(upgrade_M); diff --git a/sources/qetmainwindow.h b/sources/qetmainwindow.h index 9ad1862f0..c3820c4bb 100644 --- a/sources/qetmainwindow.h +++ b/sources/qetmainwindow.h @@ -66,6 +66,7 @@ class QETMainWindow : public QMainWindow { QAction *donate_; ///< Launch browser to donate link QAction *about_qt_; ///< launch the "About Qt" dialog QAction *diagnostics_action_; ///< Open the diagnostics report dialog (discussion #644, step 5) + QAction *connect_ai_; ///< Show how to connect an AI assistant (MCP server) QMenu *settings_menu_; ///< Settings menu QMenu *help_menu_; ///< Help menu QMenu *display_toolbars_; ///< Show/hide toolbars/docks diff --git a/sources/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp new file mode 100644 index 000000000..b9f7ac355 --- /dev/null +++ b/sources/ui/aiassistantdialog.cpp @@ -0,0 +1,198 @@ +/* + 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 . +*/ +#include "aiassistantdialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +namespace { +const char *const GUIDE_URL = + "https://github.com/qelectrotech/qelectrotech-source-mirror/wiki/ai_assistants"; +} + +AiAssistantDialog::AiAssistantDialog(QWidget *parent) : + QDialog(parent) +{ + setWindowTitle(tr("Connecter un assistant IA")); + resize(720, 560); + +#ifdef Q_OS_WIN + const bool windows = true; +#else + const bool windows = false; +#endif + m_paths = AiAssistantSetup::detect(QCoreApplication::applicationDirPath(), + QCoreApplication::applicationFilePath(), + windows); + + auto *layout = new QVBoxLayout(this); + + auto *intro = new QLabel( + tr("Un assistant IA (Claude, GitHub Copilot, Gemini…) peut ouvrir, " + "vérifier et modifier vos schémas grâce au serveur MCP de " + "QElectroTech, qui fonctionne sur cet ordinateur. Copiez le texte " + "ci-dessous dans la configuration de votre assistant. " + "Guide détaillé").arg(QLatin1String(GUIDE_URL)), + this); + intro->setWordWrap(true); + intro->setOpenExternalLinks(true); + layout->addWidget(intro); + + if (m_paths.server.isEmpty()) { + auto *missing = new QLabel( + tr("Le serveur MCP n'est pas installé avec cette version de " + "QElectroTech. Le guide explique comment l'obtenir."), + this); + missing->setWordWrap(true); + layout->addWidget(missing); + } + + auto *form = new QFormLayout(); + m_client = new QComboBox(this); + m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop)); + m_client->addItem(QStringLiteral("Claude Code"), int(Client::ClaudeCode)); + m_client->addItem(QStringLiteral("GitHub Copilot (VS Code)"), int(Client::VsCode)); + m_client->addItem(QStringLiteral("Cursor"), int(Client::Cursor)); + m_client->addItem(QStringLiteral("Gemini CLI"), int(Client::GeminiCli)); + m_client->addItem(QStringLiteral("Codex CLI"), int(Client::CodexCli)); + m_client->addItem(QStringLiteral("LM Studio"), int(Client::LmStudio)); + form->addRow(tr("Assistant :"), m_client); + + auto *workspace_row = new QHBoxLayout(); + m_workspace = new QLineEdit(this); + m_workspace->setPlaceholderText(tr("Le dossier de vos schémas")); + auto *browse = new QPushButton(tr("Parcourir…"), this); + workspace_row->addWidget(m_workspace); + workspace_row->addWidget(browse); + form->addRow(tr("Dossier accessible :"), workspace_row); + + m_allow_edit = new QCheckBox(tr("Autoriser l'assistant à modifier les schémas"), this); + form->addRow(QString(), m_allow_edit); + layout->addLayout(form); + + auto *scope = new QLabel( + tr("L'assistant ne peut lire et écrire que dans ce dossier. Sans " + "modification autorisée, il peut seulement lire, comparer et " + "exporter. Un assistant lit le texte des projets (repères, " + "notes…) : un texte écrit comme une instruction peut " + "l'influencer. Laissez les modifications désactivées pour les " + "schémas reçus d'autres personnes."), + this); + scope->setWordWrap(true); + layout->addWidget(scope); + + m_where = new QLabel(this); + m_where->setWordWrap(true); + m_where->setTextInteractionFlags(Qt::TextSelectableByMouse); + layout->addWidget(m_where); + + m_text = new QPlainTextEdit(this); + m_text->setReadOnly(true); + m_text->setLineWrapMode(QPlainTextEdit::NoWrap); + m_text->setFont(QFontDatabase::systemFont(QFontDatabase::FixedFont)); + layout->addWidget(m_text); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, this); + m_copy = buttons->addButton(tr("Copier"), QDialogButtonBox::ActionRole); + layout->addWidget(buttons); + + connect(m_client, QOverload::of(&QComboBox::currentIndexChanged), + this, &AiAssistantDialog::refresh); + connect(m_workspace, &QLineEdit::textChanged, this, &AiAssistantDialog::refresh); + connect(m_allow_edit, &QCheckBox::toggled, this, &AiAssistantDialog::refresh); + connect(browse, &QPushButton::clicked, this, &AiAssistantDialog::chooseWorkspace); + connect(m_copy, &QPushButton::clicked, this, &AiAssistantDialog::copy); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + refresh(); +} + +void AiAssistantDialog::refresh() +{ + const auto client = Client(m_client->currentData().toInt()); + m_where->setText(whereItGoes(client) + QLatin1Char(' ') + + tr("Si le fichier contient déjà d'autres serveurs, " + "ajoutez seulement l'entrée « qet ».")); + + const QString workspace = m_workspace->text().trimmed(); + const bool ready = !m_paths.server.isEmpty() && !workspace.isEmpty(); + m_copy->setEnabled(ready); + if (m_paths.server.isEmpty()) + m_text->setPlainText(QString()); + else if (workspace.isEmpty()) + m_text->setPlainText(tr("Choisissez d'abord le dossier de vos schémas.")); + else + m_text->setPlainText(AiAssistantSetup::configuration( + client, m_paths, workspace, m_allow_edit->isChecked())); +} + +void AiAssistantDialog::chooseWorkspace() +{ + const QString dir = QFileDialog::getExistingDirectory( + this, tr("Dossier accessible à l'assistant"), m_workspace->text()); + if (!dir.isEmpty()) + m_workspace->setText(QDir::toNativeSeparators(dir)); +} + +void AiAssistantDialog::copy() +{ + QApplication::clipboard()->setText(m_text->toPlainText()); + m_copy->setText(tr("Copié")); +} + +QString AiAssistantDialog::whereItGoes(Client client) const +{ + switch (client) { + case Client::ClaudeDesktop: + return tr("Dans Claude Desktop : Paramètres → Développeur → " + "Modifier la configuration. Puis quittez et relancez Claude."); + case Client::ClaudeCode: + return tr("Enregistrez-le sous le nom .mcp.json dans le dossier de vos schémas."); + case Client::VsCode: + return tr("Enregistrez-le sous le nom .vscode/mcp.json dans le dossier " + "ouvert dans VS Code. Copilot utilise les outils en mode agent."); + case Client::Cursor: + return tr("Ajoutez-le au fichier .cursor/mcp.json de votre dossier " + "personnel."); + case Client::GeminiCli: + return tr("Ajoutez-le au fichier .gemini/settings.json de votre dossier " + "personnel. Gemini CLI demande de faire confiance au dossier " + "la première fois."); + case Client::CodexCli: + return tr("Ajoutez-le au fichier .codex/config.toml de votre dossier " + "personnel."); + case Client::LmStudio: + return tr("Dans LM Studio : onglet Program → Install → Edit mcp.json."); + } + return QString(); +} diff --git a/sources/ui/aiassistantdialog.h b/sources/ui/aiassistantdialog.h new file mode 100644 index 000000000..18a3f7f1a --- /dev/null +++ b/sources/ui/aiassistantdialog.h @@ -0,0 +1,61 @@ +/* + 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 . +*/ +#ifndef AIASSISTANTDIALOG_H +#define AIASSISTANTDIALOG_H + +#include "aiassistantsetup.h" + +#include + +class QCheckBox; +class QComboBox; +class QLabel; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; + +/** + @brief The AiAssistantDialog class + Help > Connect an AI assistant: shows the configuration an assistant + needs to start QElectroTech's MCP server, with this installation's + paths filled in, for the user to copy into that assistant. It writes + nothing and starts nothing. +*/ +class AiAssistantDialog : public QDialog +{ + Q_OBJECT + + public: + explicit AiAssistantDialog(QWidget *parent = nullptr); + + private: + void refresh(); + void chooseWorkspace(); + void copy(); + QString whereItGoes(AiAssistantSetup::Client client) const; + + AiAssistantSetup::Paths m_paths; + QComboBox *m_client = nullptr; + QLineEdit *m_workspace = nullptr; + QCheckBox *m_allow_edit = nullptr; + QLabel *m_where = nullptr; + QPlainTextEdit *m_text = nullptr; + QPushButton *m_copy = nullptr; +}; + +#endif // AIASSISTANTDIALOG_H diff --git a/sources/ui/aiassistantsetup.cpp b/sources/ui/aiassistantsetup.cpp new file mode 100644 index 000000000..d484fc6e0 --- /dev/null +++ b/sources/ui/aiassistantsetup.cpp @@ -0,0 +1,141 @@ +/* + 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 . +*/ +#include "aiassistantsetup.h" + +#include +#include +#include +#include +#include + +namespace { + +/** + The first of \a candidates that is a file, as a clean absolute path, + or an empty string. +*/ +QString firstFile(const QStringList &candidates) +{ + for (const QString &c : candidates) { + const QFileInfo info(c); + if (info.isFile()) + return QDir::cleanPath(info.absoluteFilePath()); + } + return QString(); +} + +QJsonObject environment(const AiAssistantSetup::Paths &paths, + const QString &workspace, + bool allow_edit) +{ + QJsonObject env; + env.insert(QStringLiteral("QET_MCP_WORKSPACE"), QDir::toNativeSeparators(workspace)); + env.insert(QStringLiteral("QET_BINARY"), QDir::toNativeSeparators(paths.qet_binary)); + if (allow_edit) + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + return env; +} + +/// A TOML basic string: backslashes and quotes escaped. +QString toml(const QString &s) +{ + QString out = s; + out.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); + out.replace(QLatin1Char('"'), QStringLiteral("\\\"")); + return QLatin1Char('"') + out + QLatin1Char('"'); +} + +} // namespace + +QList AiAssistantSetup::clients() +{ + return {Client::ClaudeDesktop, Client::ClaudeCode, Client::VsCode, + Client::Cursor, Client::GeminiCli, Client::CodexCli, + Client::LmStudio}; +} + +/** + @brief AiAssistantSetup::detect + Finds the server beside this QElectroTech, in the two places its + packages put it: /mcp/ beside /bin/ (the Windows installers + and portable folder) and /share/qelectrotech/mcp/ beside + /bin/ (make install). On Windows the Python the installer can + add is preferred to one on PATH. + @param application_dir : QCoreApplication::applicationDirPath() + @param application_file : QCoreApplication::applicationFilePath() + @param windows : true on Windows, where Python is "python", not "python3" +*/ +AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, + const QString &application_file, + bool windows) +{ + const QDir bin(application_dir); + Paths paths; + paths.qet_binary = QDir::cleanPath(application_file); + paths.server = firstFile({ + bin.filePath(QStringLiteral("../mcp/qet_mcp.py")), + bin.filePath(QStringLiteral("../share/qelectrotech/mcp/qet_mcp.py"))}); + + paths.python = windows ? QStringLiteral("python") : QStringLiteral("python3"); + if (windows) { + const QString bundled = firstFile({ + bin.filePath(QStringLiteral("../mcp/python/python.exe"))}); + if (!bundled.isEmpty()) + paths.python = bundled; + } + return paths; +} + +/** + @brief AiAssistantSetup::configuration + @return the text to paste into \a client's configuration: JSON for all + but Codex CLI, which reads TOML. Windows paths come out with native + separators, escaped as each format requires. +*/ +QString AiAssistantSetup::configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit) +{ + const QString python = QDir::toNativeSeparators(paths.python); + const QString server = QDir::toNativeSeparators(paths.server); + const QJsonObject env = environment(paths, workspace, allow_edit); + + if (client == Client::CodexCli) { + QString text = QStringLiteral("[mcp_servers.qet]\n"); + text += QStringLiteral("command = ") + toml(python) + QLatin1Char('\n'); + text += QStringLiteral("args = [") + toml(server) + QStringLiteral("]\n\n"); + text += QStringLiteral("[mcp_servers.qet.env]\n"); + for (auto it = env.constBegin(); it != env.constEnd(); ++it) + text += it.key() + QStringLiteral(" = ") + toml(it.value().toString()) + QLatin1Char('\n'); + return text; + } + + QJsonObject server_entry; + if (client == Client::VsCode || client == Client::Cursor) + server_entry.insert(QStringLiteral("type"), QStringLiteral("stdio")); + server_entry.insert(QStringLiteral("command"), python); + server_entry.insert(QStringLiteral("args"), QJsonArray{server}); + server_entry.insert(QStringLiteral("env"), env); + + // VS Code names the list "servers"; every other client, "mcpServers". + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + const QJsonObject root{{key, QJsonObject{{QStringLiteral("qet"), server_entry}}}}; + return QString::fromUtf8(QJsonDocument(root).toJson(QJsonDocument::Indented)); +} diff --git a/sources/ui/aiassistantsetup.h b/sources/ui/aiassistantsetup.h new file mode 100644 index 000000000..144a74020 --- /dev/null +++ b/sources/ui/aiassistantsetup.h @@ -0,0 +1,64 @@ +/* + 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 . +*/ +#ifndef AIASSISTANTSETUP_H +#define AIASSISTANTSETUP_H + +#include +#include + +/** + @brief What an AI assistant needs to start QElectroTech's MCP server + (misc/qet-mcp), and the configuration text each assistant expects. + + No widgets and no application object, so it can be tested alone. It + reads the file system and nothing else: it writes nothing and starts + nothing. AiAssistantDialog shows its result. +*/ +namespace AiAssistantSetup +{ + /// The assistants a configuration can be written for. + enum class Client { + ClaudeDesktop, + ClaudeCode, + VsCode, + Cursor, + GeminiCli, + CodexCli, + LmStudio + }; + + QList clients(); + + /// Where the pieces are. An empty server means none was found. + struct Paths { + QString server; ///< qet_mcp.py + QString python; ///< the Python to run it with + QString qet_binary; ///< this QElectroTech + }; + + Paths detect(const QString &application_dir, + const QString &application_file, + bool windows); + + QString configuration(Client client, + const Paths &paths, + const QString &workspace, + bool allow_edit); +} + +#endif // AIASSISTANTSETUP_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e10e2ad16..b9422de60 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -212,6 +212,16 @@ add_test(NAME tst_scriptingsetting COMMAND tst_scriptingsetting) target_include_directories(tst_scriptingsetting PRIVATE ${QET_DIR}/sources) target_link_libraries(tst_scriptingsetting PRIVATE Qt::Test Qt::Gui) +# AiAssistantSetup: finding the MCP server beside an installation, and the +# configuration text for each AI assistant. Compiles the one file alone. +add_executable( + tst_aiassistantsetup + tst_aiassistantsetup.cpp + ${QET_DIR}/sources/ui/aiassistantsetup.cpp) +add_test(NAME tst_aiassistantsetup COMMAND tst_aiassistantsetup) +target_include_directories(tst_aiassistantsetup PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_aiassistantsetup PRIVATE Qt::Test Qt::Core) + # QetSettings::sheetBackground() -- the sheet (folio) background the diagram # editor remembers between runs. Compiles qetsettings.cpp alone for the same # reason as the test above; Qt::Gui because the pair carries a QColor. diff --git a/tests/qttest/tst_aiassistantsetup.cpp b/tests/qttest/tst_aiassistantsetup.cpp new file mode 100644 index 000000000..09912077e --- /dev/null +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -0,0 +1,149 @@ +/* + 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 . +*/ +#include "ui/aiassistantsetup.h" + +#include +#include +#include +#include +#include +#include +#include + +using AiAssistantSetup::Client; + +class TestAiAssistantSetup : public QObject +{ + Q_OBJECT + + static void touch(const QString &path) + { + QDir().mkpath(QFileInfo(path).absolutePath()); + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + } + + static AiAssistantSetup::Paths windowsPaths() + { + AiAssistantSetup::Paths p; + p.server = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\qet_mcp.py"); + p.python = QStringLiteral("C:\\Program Files\\QElectroTech\\mcp\\python\\python.exe"); + p.qet_binary = QStringLiteral("C:\\Program Files\\QElectroTech\\bin\\QElectroTech.exe"); + return p; + } + + static QJsonObject entry(Client client, const AiAssistantSetup::Paths &p, bool edit) + { + const QByteArray text = AiAssistantSetup::configuration( + client, p, QStringLiteral("D:\\Drawings"), edit).toUtf8(); + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(text, &error); + if (error.error != QJsonParseError::NoError) + qWarning() << error.errorString() << text; + const QString key = client == Client::VsCode ? QStringLiteral("servers") + : QStringLiteral("mcpServers"); + return doc.object().value(key).toObject().value(QStringLiteral("qet")).toObject(); + } + + private slots: + void windowsInstallIsFound() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("bin/QElectroTech.exe"))); + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.server, QDir::cleanPath(root.filePath(QStringLiteral("mcp/qet_mcp.py")))); + QCOMPARE(p.python, QDir::cleanPath(root.filePath(QStringLiteral("mcp/python/python.exe")))); + QCOMPARE(p.qet_binary, QDir::cleanPath(root.filePath(QStringLiteral("bin/QElectroTech.exe")))); + } + + void windowsWithoutBundledPythonUsesPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true); + QCOMPARE(p.python, QStringLiteral("python")); + } + + void unixInstallIsFound() + { + QTemporaryDir prefix; + touch(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py"))); + // A bundled-Python layout is only a Windows thing. + touch(prefix.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(prefix.filePath(QStringLiteral("bin"))); + const auto p = AiAssistantSetup::detect(prefix.filePath(QStringLiteral("bin")), + prefix.filePath(QStringLiteral("bin/qelectrotech")), false); + QCOMPARE(p.server, QDir::cleanPath(prefix.filePath(QStringLiteral("share/qelectrotech/mcp/qet_mcp.py")))); + QCOMPARE(p.python, QStringLiteral("python3")); + } + + void noServerIsReportedAsEmpty() + { + QTemporaryDir build; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), false); + QVERIFY(p.server.isEmpty()); + } + + void everyJsonClientGetsValidJson() + { + for (Client c : AiAssistantSetup::clients()) { + if (c == Client::CodexCli) + continue; + const QJsonObject e = entry(c, windowsPaths(), false); + QVERIFY2(!e.isEmpty(), qPrintable(QString::number(int(c)))); + // Backslashes survive: parsing the text gives the path back. + QCOMPARE(e.value(QStringLiteral("args")).toArray().at(0).toString(), windowsPaths().server); + QCOMPARE(e.value(QStringLiteral("command")).toString(), windowsPaths().python); + const QJsonObject env = e.value(QStringLiteral("env")).toObject(); + QCOMPARE(env.value(QStringLiteral("QET_MCP_WORKSPACE")).toString(), QStringLiteral("D:\\Drawings")); + QCOMPARE(env.value(QStringLiteral("QET_BINARY")).toString(), windowsPaths().qet_binary); + const bool typed = c == Client::VsCode || c == Client::Cursor; + QCOMPARE(e.contains(QStringLiteral("type")), typed); + } + } + + void editingIsOffUnlessAllowed() + { + for (Client c : AiAssistantSetup::clients()) { + const QString off = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), false); + const QString on = AiAssistantSetup::configuration(c, windowsPaths(), QStringLiteral("D:\\x"), true); + QVERIFY(!off.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + QVERIFY(on.contains(QStringLiteral("QET_ENABLE_SCRIPTING"))); + } + } + + void codexGetsEscapedToml() + { + const QString t = AiAssistantSetup::configuration( + Client::CodexCli, windowsPaths(), QStringLiteral("D:\\Drawings"), true); + QVERIFY(t.startsWith(QStringLiteral("[mcp_servers.qet]\n"))); + QVERIFY(t.contains(QStringLiteral("args = [\"C:\\\\Program Files\\\\QElectroTech\\\\mcp\\\\qet_mcp.py\"]"))); + QVERIFY(t.contains(QStringLiteral("[mcp_servers.qet.env]\n"))); + QVERIFY(t.contains(QStringLiteral("QET_MCP_WORKSPACE = \"D:\\\\Drawings\""))); + QVERIFY(t.contains(QStringLiteral("QET_ENABLE_SCRIPTING = \"1\""))); + } +}; + +QTEST_GUILESS_MAIN(TestAiAssistantSetup) +#include "tst_aiassistantsetup.moc" From 0075ee208ff3c5da9e2df5965a78a368546808a6 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 14:00:02 +1300 Subject: [PATCH 29/45] qet-mcp: exact-answer tests for qet_check, qet_continuity and the .elmt tools A mutation audit of the functions no audit had covered (tests needing no QElectroTech): these 13 caught 153 of 337 planted bugs (45 %). With these tests, 322 (96 %). qet_check was at 26/59 and qet_continuity at 24/37 even with the binary tests, since those look at a finding or two. - qet_check / qet_continuity: _run_qet replaced by a stub returning chosen log lines, so the whole answer is compared -- summary counts, "ok", passed, check_failures, each finding's count, note and sampled rows, sorting by severity, folio_number, the launch hint carried through, the folio argument in the script, folio bounds, and lines that only look like ours. - qet_element_build / qet_element_info: the written header, names, kind information and terminals, and the reported result, key for key; the refusals; geometry worked out by hand; every part kind's extent and written attributes; number formatting. - qet_element_search and its index: the index entry key for key, the cache, ranking (exact name, then first word, then length), the default and given limits. The 15 left are equivalent: timeouts and output limits, a ranking constant that only has to exceed 0, and a containment check the 5-unit margin keeps from ever failing. Tests only; 282/282. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/test_qet_mcp.py | 386 +++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 24c1e2718..97d0a812f 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -30,6 +30,7 @@ import subprocess import sys import tempfile import unittest +from unittest import mock import xml.etree.ElementTree as ET from pathlib import Path @@ -709,6 +710,391 @@ class ElementBuild(unittest.TestCase): self.assertEqual(r["verified"]["names"]["en"], 'Coil "A" & ') +class ElementFileExact(unittest.TestCase): + """Exact answers for the tools that write and read .elmt files and need + no QElectroTech. Found by a mutation audit: the existing tests checked a + few fields, so dropping any other one from a result, or getting a + header number off by one, went unnoticed.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) + m._ELEMENT_INDEX.clear() + + def tearDown(self): + self.tmp.cleanup() + + def test_fmt(self): + self.assertEqual([m._fmt(v) for v in (True, False, 3, 2.0, 2.5, -0.25, "x")], + ["true", "false", "3", "2", "2.5", "-0.25", "x"]) + + def test_part_extent_per_kind(self): + ext = m._part_extent + self.assertEqual(ext("line", {"x1": 1, "y1": 2, "x2": 3, "y2": 4}), [(1, 2), (3, 4)]) + for kind in ("rect", "ellipse", "arc"): + self.assertEqual(ext(kind, {"x": -5, "y": 1, "width": 10, "height": 4}), + [(-5, 1), (5, 5)], kind) + self.assertEqual(ext("circle", {"x": 2, "y": 3, "diameter": 6}), [(2, 3), (8, 9)]) + self.assertEqual(ext("polygon", {"points": [[0, 1], [2, 3], [4, 5]]}), + [(0, 1), (2, 3), (4, 5)]) + self.assertEqual(ext("text", {"x": 7, "y": 8, "text": "a"}), [(7, 8)]) + self.assertEqual(ext("dynamic_text", {"x": 7}), []) + + def test_element_geometry(self): + """Box, hotspot and size, worked out by hand: points reach x -10..20 + and y -15..5; a 5 unit margin, rounded out to tens.""" + g = m._element_geometry( + [{"type": "line", "x1": 0, "y1": 0, "x2": 20, "y2": 0}, + {"type": "rect", "x": -10, "y": -5, "width": 10, "height": 10}], + [{"x": 0, "y": -15, "orientation": "n"}]) + self.assertEqual(g, {"width": 50, "height": 30, "hotspot_x": 20, "hotspot_y": 20, + "bbox": [-10, -15, 20, 5]}) + # a terminal alone is enough; nothing at all is refused + self.assertEqual(m._element_geometry([], [{"x": 0, "y": 0, "orientation": "n"}]), + {"width": 20, "height": 20, "hotspot_x": 10, "hotspot_y": 10, "bbox": [0, 0, 0, 0]}) + with self.assertRaisesRegex(ValueError, "at least one part or terminal"): + m._element_geometry([], []) + + def test_part_element(self): + E = lambda part: dict(m._part_element(part, "{u}").attrib) + self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2.5, 3]]}), + {"uuid": "{u}", "x1": "0", "y1": "1", "x2": "2.5", "y2": "3", + "closed": "true", "antialias": "true", "style": m.DEFAULT_STYLE}) + self.assertEqual(E({"type": "polygon", "points": [[0, 1], [2, 3]], "closed": False, + "antialias": False, "style": "x"})["closed"], "false") + self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1"}), + {"uuid": "{u}", "x": "1", "y": "2", "text": "K1", "rotation": "0", + "font": "Sans Serif,9,-1,5,50,0,0,0,0,0", "color": "#000000"}) + self.assertEqual(E({"type": "text", "x": 1, "y": 2, "text": "K1", "size": 12, + "rotation": 90, "color": "red"})["font"], + "Sans Serif,12,-1,5,50,0,0,0,0,0") + rect = E({"type": "rect", "x": 0, "y": 0, "width": 4, "height": 2, "antialias": False}) + self.assertEqual(rect, {"uuid": "{u}", "x": "0", "y": "0", "width": "4", "height": "2", + "antialias": "false", "style": m.DEFAULT_STYLE}) + + def test_build_writes_and_reports_exactly(self): + out = self.root / "k" / "coil.elmt" + U = "{11111111-2222-4333-8444-555555555555}" + r = m.tool_element_build( + str(out), {"fr": "Bobine", "en": "Coil"}, + [{"type": "line", "x1": 0, "y1": -10, "x2": 0, "y2": 10}], + terminals=[{"x": 0, "y": 20, "orientation": "s", "name": "A2"}, + {"x": 0, "y": -20, "orientation": "n", "name": "A1", "type": "Inner"}], + link_type="master", informations={"type": "coil"}, uuid=U) + self.assertEqual(set(r), {"ok", "output", "bytes", "width", "height", "hotspot_x", + "hotspot_y", "bbox", "terminal_index_order", "part_uuids", + "verified"}) + self.assertTrue(r["ok"]) + self.assertEqual(r["output"], str(out)) + self.assertEqual(r["bytes"], out.stat().st_size) + self.assertEqual(r["terminal_index_order"], ["A1", "A2"]) + root = ET.parse(out).getroot() + self.assertEqual(dict(root.attrib), { + "version": "0.100.0", "type": "element", "link_type": "master", + "width": str(r["width"]), "height": str(r["height"]), + "hotspot_x": str(r["hotspot_x"]), "hotspot_y": str(r["hotspot_y"])}) + self.assertEqual(root.find("uuid").get("uuid"), U) + self.assertEqual([(n.get("lang"), n.text) for n in root.iter("name")], + [("en", "Coil"), ("fr", "Bobine")]) + self.assertEqual([(k.get("name"), k.text) for k in root.iter("kindInformation")], + [("type", "coil")]) + t2, t1 = root.findall("description/terminal") + self.assertEqual({k: v for k, v in t1.attrib.items() if k != "uuid"}, + {"x": "0", "y": "-20", "orientation": "n", "type": "Inner", "name": "A1"}) + self.assertEqual(t2.get("type"), "Generic") + self.assertRegex(t1.get("uuid"), m._UUID_RE) + self.assertNotEqual(t1.get("uuid"), t2.get("uuid")) + + def test_build_refusals(self): + out = str(self.root / "x.elmt") + line = [{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 0}] + term = [{"x": 0, "y": 0, "orientation": "n"}] + for message, kwargs in [ + ("names must be a non-empty", dict(names={}, parts=line, terminals=term)), + ("unknown link_type", dict(names={"en": "a"}, parts=line, terminals=term, link_type="x")), + ("parts must be a list", dict(names={"en": "a"}, parts={}, terminals=term)), + ("terminal 0 is not an object", dict(names={"en": "a"}, parts=line, terminals=[1])), + ("terminal 0 is missing 'orientation'", dict(names={"en": "a"}, parts=line, + terminals=[{"x": 0, "y": 0}])), + ("orientation is one of", dict(names={"en": "a"}, parts=line, + terminals=[{"x": 0, "y": 0, "orientation": "up"}])), + ("cannot be connected", dict(names={"en": "a"}, parts=line, terminals=[])), + ("same uuid", dict(names={"en": "a"}, terminals=term, parts=[ + dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}"), + dict(line[0], uuid="{11111111-2222-4333-8444-555555555555}")]))]: + with self.subTest(message=message): + with self.assertRaisesRegex(ValueError, message): + m.tool_element_build(out, **kwargs) + + def test_element_info_exactly(self): + p = self.root / "e.elmt" + p.write_text( + '' + 'E' + ' label ' + '' + '' + '' + '', + encoding="utf-8") + r = m.tool_element_info(str(p)) + self.assertEqual(set(r), {"file", "type", "link_type", "width", "height", "names", + "terminal_count", "terminals", "terminal_order", + "info_fields", "parts", "part_list"}) + self.assertEqual((r["file"], r["type"], r["link_type"], r["width"], r["height"]), + (str(p), "element", "simple", "20", "40")) + self.assertEqual(r["terminal_count"], 3) + self.assertEqual(r["terminals"][0], {"index": 0, "x": "0", "y": "-10", "orientation": "n", + "name": "1", "type": "", "uuid": ""}) + self.assertEqual(r["terminals"][2], {"index": 2, "x": "0", "y": "10", "orientation": "s", + "name": "2", "type": "Generic", "uuid": "{B}"}) + self.assertEqual(r["info_fields"], ["label"]) + self.assertEqual(r["parts"], {"line": 1, "terminal": 3, "arc": 1}) + self.assertEqual(r["part_list"], [{"type": "line", "uuid": "{L}"}, {"type": "arc", "uuid": ""}]) + self.assertNotIn("undefined", r["terminal_order"]) + p.write_text(p.read_text().replace('x="5" y="-10"', 'x="0" y="-10"'), encoding="utf-8") + self.assertIn("undefined", m.tool_element_info(str(p))["terminal_order"]) + + def test_index_entry_exactly(self): + f = self.root / "a" / "k.elmt" + f.parent.mkdir() + f.write_text('' + 'Bobine' + ' coil ' + 'x' + '' + '', + encoding="utf-8") + (self.root / "a" / "broken.elmt").write_text("", encoding="utf-8") + items = m._index_collection(self.root) + self.assertEqual(len(items), 1) + it = {k: v for k, v in items[0].items() if k != "haystack"} + self.assertEqual(it, {"path": "common://a/k.elmt", "file": str(f), "name": "Bobine", + "names": {"fr": "Bobine"}, "link_type": "master", "kind": "coil", + "terminals": 2, "terminal_names": ["A2", "A1"], + "terminal_order_ambiguous": True, "width": "30", "height": "50"}) + # the index is cached until the collection changes + self.assertIs(m._index_collection(self.root), items) + self.assertEqual(m._collection_signature(self.root)[0], 3) + + def test_search_ranking_and_limit(self): + for rel, name in (("a/1.elmt", "Coil latching"), ("a/2.elmt", "Coil"), + ("a/3.elmt", "Remanence coil")): + f = self.root / rel + f.parent.mkdir(exist_ok=True) + f.write_text(f'' + f'{name}' + f'', + encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil") + self.assertEqual([e["name"] for e in r["results"]], + ["Coil", "Coil latching", "Remanence coil"]) + r = m.tool_element_search(str(self.root), "coil", limit=1) + self.assertEqual((r["total_matches"], r["returned"]), (3, 1)) + with self.assertRaisesRegex(ValueError, "limit must be >= 1"): + m.tool_element_search(str(self.root), "coil", limit=0) + self.assertEqual(m.tool_element_search(str(self.root), "coil", limit=1)["results"][0]["name"], + "Coil") + self.assertEqual(set(r), {"query", "total_matches", "returned", "indexed", "results"}) + self.assertEqual((r["query"], r["indexed"]), ("coil", 3)) + self.assertEqual(r["results"][0]["languages"], ["en"]) + + def test_search_puts_the_exact_name_then_names_starting_with_it(self): + for n, name in enumerate(("Relay coil", "Coil relay", "Coil relay X", "A coil relay")): + f = self.root / f"{n}.elmt" + f.write_text(f'' + f'{name}' + f'', + encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil relay") + self.assertEqual([e["name"] for e in r["results"]], + ["Coil relay", "Coil relay X", "Relay coil", "A coil relay"]) + + def test_search_returns_25_by_default(self): + for n in range(26): + (self.root / f"{n:02}.elmt").write_text( + f'Coil {n}' + f'' + f'', encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil") + self.assertEqual((r["total_matches"], r["returned"]), (26, 25)) + + def test_search_exact_name_wins_a_tie(self): + """Same length, same first word: the exact name comes first, not + the one whose path sorts first.""" + for rel, name in (("a.elmt", "Coil-a"), ("z.elmt", "Coil a")): + (self.root / rel).write_text( + f'{name}' + f'' + f'', encoding="utf-8") + r = m.tool_element_search(str(self.root), "coil a") + self.assertEqual([e["name"] for e in r["results"]], ["Coil a", "Coil-a"]) + + def test_validate_part_refusals(self): + ok = lambda part: m._validate_part(0, part) + self.assertEqual(ok({"type": "polygon", "points": [[0, 0], [1, 1]]}), "polygon") + for message, part in [ + ("not an object", ["line"]), + ("at least two points", {"type": "polygon", "points": [[0, 0]]}), + ("at least two points", {"type": "polygon", "points": "ab"}), + ("each point is", {"type": "polygon", "points": [[0, 0], [1, 1, 1]]}), + ("each point is", {"type": "polygon", "points": [[0, 0], 5]}), + ("uuid must look like", {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1, + "uuid": "L1"})]: + with self.subTest(message=message): + with self.assertRaisesRegex(ValueError, message): + ok(part) + + def test_terminal_order_with_unreadable_coordinates(self): + T = lambda **a: ET.Element("terminal", {k: str(v) for k, v in a.items()}) + ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, y="bad", name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(x="bad", y=0, name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + # a missing coordinate counts as 0 + ordered, _ = m._terminals_in_index_order([T(x=0, y=0.5, name="b"), T(x=0, name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + ordered, _ = m._terminals_in_index_order([T(x=0.5, y=0, name="b"), T(y=0, name="a")]) + self.assertEqual([t.get("name") for t in ordered], ["a", "b"]) + + +class CheckAndContinuityAnswers(unittest.TestCase): + """qet_check and qet_continuity turn QElectroTech's log lines into their + answer. With _run_qet replaced by a stub that returns chosen lines, the + whole answer can be checked exactly, without QElectroTech. Found by a + mutation audit: the binary tests look at a finding or two, so a wrong + summary count, a dropped field or a wrong "passed" went unnoticed.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.qet = Path(self.tmp.name) / "p.qet" + self.qet.write_text('', + encoding="utf-8") + + def tearDown(self): + self.tmp.cleanup() + + def stub(self, lines, **extra): + out = "\n".join(["noise", m._MARKER + "{not json"] + + [m._MARKER + json.dumps(l) for l in lines]) + return mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": out, "stderr": "", **extra}) + + def test_check_answer_exactly(self): + rows = [{"label": f"K{i}"} for i in range(12)] + lines = [ + {"kind": "check", "name": "duplicate_master_labels", "rows": rows, "error": ""}, + {"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""}, + {"kind": "check", "name": "unnumbered_conductors", "rows": [{"n": 1}, {"n": 2}], "error": ""}, + {"kind": "check", "name": "duplicate_simple_labels", "rows": [], "error": ""}, + {"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"}, + {"kind": "other", "name": "masters_without_manufacturer_reference", "rows": [1]}, + ] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet)) # sample defaults to 10 + C = m.CHECKS + self.assertEqual(r, { + "ok": False, + "summary": {"errors": 1, "warnings": 1, "info": 1, "passed": 1, "check_failures": 2}, + "findings": [ + {"check": "duplicate_master_labels", "severity": "error", "count": 12, + "note": C["duplicate_master_labels"]["note"], "rows": rows[:10]}, + {"check": "unlabelled_masters", "severity": "warning", "count": 1, + "note": C["unlabelled_masters"]["note"], "rows": [{"x": 1}]}, + {"check": "unnumbered_conductors", "severity": "info", "count": 2, + "note": C["unnumbered_conductors"]["note"], "rows": [{"n": 1}, {"n": 2}]}], + "passed": ["duplicate_simple_labels"], + "check_failures": [ + {"check": "empty_folios", "error": "bad SQL"}, + {"check": "masters_without_manufacturer_reference", "error": "no result came back"}]}) + + def test_check_sorts_by_severity_then_name(self): + lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [1], "error": ""}, + {"kind": "check", "name": "empty_folios", "rows": [1], "error": ""}] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios", "unlabelled_masters"]) + self.assertEqual([f["check"] for f in r["findings"]], ["unlabelled_masters", "empty_folios"]) + + def test_check_failure_alone_is_not_ok(self): + lines = [{"kind": "check", "name": "empty_folios", "rows": None, "error": "bad SQL"}] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios"]) + self.assertFalse(r["ok"]) + + def test_check_ignores_a_line_without_the_marker(self): + almost = "x" * (len(m._MARKER) - 1) + json.dumps( + {"kind": "check", "name": "empty_folios", "rows": [1], "error": ""}) + with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios"]) + self.assertEqual(r["check_failures"], [{"check": "empty_folios", "error": "no result came back"}]) + + def test_check_ok_without_errors_and_sample_zero(self): + lines = [{"kind": "check", "name": "unlabelled_masters", "rows": [{"x": 1}], "error": ""}] + with self.stub(lines): + r = m.tool_check("qet", str(self.qet), checks=["unlabelled_masters"], sample=0) + self.assertTrue(r["ok"]) # a warning is not a failure + self.assertEqual(r["findings"][0]["rows"], []) + self.assertEqual(r["findings"][0]["count"], 1) + with self.assertRaisesRegex(ValueError, "sample must be >= 0"): + m.tool_check("qet", str(self.qet), sample=-1) + with self.assertRaisesRegex(ValueError, "no such project"): + m.tool_check("qet", str(self.qet) + ".missing") + + def test_check_carries_the_launch_hint(self): + with self.stub([], hint="why it did not start"): + r = m.tool_check("qet", str(self.qet), checks=["empty_folios"]) + self.assertFalse(r["ok"]) + self.assertEqual(r["hint"], "why it did not start") + + def test_continuity_answer_exactly(self): + found = [{"severity": "error", "folio": 1, "what": "a"}, + {"severity": "warning", "folio": 0, "what": "b"}, + {"severity": "info", "folio": "?", "what": "c"}, + {"severity": "info", "what": "d"}, + {"severity": "info", "folio": 1, "what": "e"}] + with self.stub([{"kind": "continuity", "findings": found}, + {"kind": "other", "findings": []}]): + r = m.tool_continuity("qet", str(self.qet), folio=1) + self.assertEqual((r["finding_count"], r["errors"], r["warnings"], r["info"]), (5, 1, 1, 3)) + self.assertEqual([f.get("folio_number") for f in r["findings"]], [2, 1, None, None, 2]) + self.assertNotIn(m._MARKER, r["stdout"]) + self.assertIn("noise", r["stdout"]) + + def test_continuity_passes_the_folio_to_the_script(self): + seen = [] + def fake(binary, args, **kw): + seen.append(kw["script"]) + return {"stdout": m._MARKER + json.dumps({"kind": "continuity", "findings": []}), + "stderr": ""} + with mock.patch.object(m, "_run_qet", fake): + m.tool_continuity("qet", str(self.qet)) + m.tool_continuity("qet", str(self.qet), folio=1) + self.assertIn("qet.checkContinuity(-1)", seen[0]) + self.assertIn("qet.checkContinuity(1)", seen[1]) + with self.assertRaisesRegex(ValueError, "no such project"): + m.tool_continuity("qet", str(self.qet) + ".missing") + almost = "x" * (len(m._MARKER) - 1) + json.dumps({"kind": "continuity", "findings": []}) + with mock.patch.object(m, "_run_qet", lambda *a, **k: {"stdout": almost, "stderr": ""}): + self.assertFalse(m.tool_continuity("qet", str(self.qet))["ok"]) + + def test_continuity_without_findings_is_not_ok(self): + with self.stub([]): + r = m.tool_continuity("qet", str(self.qet)) + self.assertFalse(r["ok"]) + self.assertIn("predate qet.checkContinuity()", r["hint"]) + with self.stub([], hint="launch failed"): + self.assertEqual(m.tool_continuity("qet", str(self.qet))["hint"], "launch failed") + + def test_continuity_folio_bounds(self): + for bad in (-1, 2): + with self.subTest(folio=bad): + with self.assertRaisesRegex(ValueError, f"folio {bad} does not exist"): + m.tool_continuity("qet", str(self.qet), folio=bad) + with self.stub([{"kind": "continuity", "findings": []}]): + self.assertEqual(m.tool_continuity("qet", str(self.qet), folio=0)["finding_count"], 0) + + class ElementSearch(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() From 3139fa54f7f96971cdf60b2690b4cba61fc97dec Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 14:14:15 +1300 Subject: [PATCH 30/45] Fix folio dates a century early in the project database diagram_info.date -- and project_summary_view.date, which the summary table dialog queries -- held the folio date read back from the title block's text: QLocale::system().toDate() of the string updateDiagramContextForTitleBlock() writes in the locale's short format. Where that format has a two-digit year (en_US "M/d/yy") Qt reads the year back as 19xx, so 741.qet's 2010-09-21 became 1910-09-21, and so did every dated folio of the 24 examples (109 of 109). bindDiagramInfoValues() now binds the folio's own date, BorderTitleBlock::date(), the value that text was made from. A folio set to show the current date or none gives the same date as before. tst_dbfoliodate runs --run on examples/741.qet under LC_ALL=en_US.UTF-8 and expects 2010-09-21 from project_summary_view: 1910-09-21 without this change. Co-Authored-By: Claude Opus 5.5 --- sources/dataBase/projectdatabase.cpp | 9 ++-- tests/qttest/CMakeLists.txt | 12 +++++ tests/qttest/tst_dbfoliodate.cpp | 72 ++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 tests/qttest/tst_dbfoliodate.cpp diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 7c6d29d8a..865deab89 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -1741,9 +1741,12 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram) for (auto key : QETInformation::diagramInfoKeys()) { if (key == "date") { - query.bindValue( ":date", - QLocale::system().toDate(infos.value("date").toString(), - QLocale::ShortFormat)); + //The folio's own date, not the title block's text read + //back: that text is the locale's short format, and where + //it has a two-digit year (en_US "M/d/yy") toDate() reads + //2010 back as 1910. + query.bindValue(QStringLiteral(":date"), + diagram->border_and_titleblock.date()); } else { auto value = infos.value(key); auto bind = key.prepend(":"); diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index e10e2ad16..1d37ab8d8 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -344,6 +344,18 @@ if(QET_HAS_SCRIPTING) target_compile_definitions(tst_scriptconductoruuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + # The project database keeps a folio's date with its century: 741.qet's + # 2010-09-21, read through project_summary_view under LC_ALL=en_US.UTF-8. + add_executable( + tst_dbfoliodate + tst_dbfoliodate.cpp) + add_test(NAME tst_dbfoliodate COMMAND tst_dbfoliodate) + add_dependencies(tst_dbfoliodate qelectrotech) + target_link_libraries(tst_dbfoliodate PRIVATE Qt::Test) + target_compile_definitions(tst_dbfoliodate PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex() # turns element uuid + terminal uuid back into the index the conductor # calls take, on examples/perceuse.qet (two terminals at one point). diff --git a/tests/qttest/tst_dbfoliodate.cpp b/tests/qttest/tst_dbfoliodate.cpp new file mode 100644 index 000000000..4a99dce71 --- /dev/null +++ b/tests/qttest/tst_dbfoliodate.cpp @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include + +// The project database holds each folio's own date. It used to hold the +// title block's text read back through the locale's short format, which in a +// locale with a two-digit year (en_US "M/d/yy") turned 2010 into 1910 -- in +// diagram_info and in project_summary_view, which the summary table reads. +// Runs a script through the real binary's --run on examples/741.qet, whose +// folio is dated 20100921, with LC_ALL=en_US.UTF-8. +class tst_dbfoliodate : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void folioDateKeepsItsCentury() + { + const QString path = m_dir.filePath(QStringLiteral("probe.js")); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QFile f(path); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("qet.log('PROBE ' + JSON.stringify(" + "qet.query('SELECT date FROM project_summary_view')));\n"); + f.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + env.insert(QStringLiteral("LC_ALL"), QStringLiteral("en_US.UTF-8")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), path, + QStringLiteral(QET_EXAMPLES_DIR "/741.qet")}); + QVERIFY(proc.waitForFinished(120000)); + const QString out = QString::fromUtf8(proc.readAllStandardOutput() + + proc.readAllStandardError()); + QJsonArray rows; + for (const QString &line : out.split(QLatin1Char('\n'))) { + const int i = line.indexOf(QStringLiteral("PROBE ")); + if (i >= 0) + rows = QJsonDocument::fromJson(line.mid(i + 6).toUtf8()).array(); + } + QCOMPARE(rows.size(), 1); + QCOMPARE(rows.at(0).toObject().value(QStringLiteral("date")).toString(), + QStringLiteral("2010-09-21")); + } +}; + +QTEST_APPLESS_MAIN(tst_dbfoliodate) + +#include "tst_dbfoliodate.moc" From 06fc5153c1aaafa659b727120252d541f67e7a18 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 14:25:06 +1300 Subject: [PATCH 31/45] qet-mcp: make QElectroTech launches work on Windows On Windows every tool that starts QElectroTech (qet_export, qet_edit, qet_query, qet_check, qet_continuity, qet_project_new) failed, for two reasons: - The server ran a copy of the executable from a temporary folder, to get its own SingleApplication key. A Windows program loads its DLLs from its own folder, so the copy died before main() with 0xC0000135 (DLL not found). Every flag the server passes is a CLI export flag or --run, and main.cpp handles both before it constructs SingleApplication, so on Windows the original is now run. The copy stays elsewhere. - It set QT_QPA_PLATFORM=offscreen. The Windows packages ship only the qwindows platform plugin, so Qt found none and stopped at a message box nobody could close: every call hung until its timeout. Windows now keeps its default platform; the export flags and --run open no window. Checked under Wine (qet-wine-smoke) on the fork's CI Windows build, run through python.org's embeddable Python: before, qet_export ended with exit 3221225781; after, a PDF export, a qet_query (98 elements, as on Linux) and a qet_edit placing a common:// element all succeed. The hang was isolated by launching the same export from bash (works) and from Python with one change at a time: only dropping QT_QPA_PLATFORM made it work. Four unit tests pin both choices per platform; each fails with its fix removed. Suite 262/262 none skipped on Linux. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 47 ++++++++++++++++++++++++++++++------ misc/qet-mcp/test_qet_mcp.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index fa3363d7e..62675672f 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -884,6 +884,44 @@ def tool_element_info(path: str) -> dict: } +def _launch_executable(src: Path, sandbox: Path, windows: bool) -> Path: + """The executable _run_qet() starts: a private copy, except on Windows. + + The copy gives each run its own SingleApplication key, which is derived + from the executable's path. On Windows a program loads its DLLs from its + own folder, so a copy on its own dies before main() (0xC0000135, DLL not + found) and nothing could ever be exported or edited there. Run the + original instead: every flag this server passes is a CLI export flag or + --run, and QElectroTech handles both and returns before it constructs + SingleApplication (main.cpp), so there is no instance to be handed to. + The copy stays elsewhere for builds from before that early return. + """ + if windows: + return src + exe = sandbox / f"qet-mcp-{os.getpid()}" + shutil.copy2(src, exe) + return exe + + +def _launch_env(env: dict, home: Path, windows: bool) -> dict: + """The environment _run_qet() starts QElectroTech in. + + A private HOME and XDG directories, and, except on Windows, Qt's + offscreen platform so no display is needed. The Windows packages ship + only the qwindows platform plugin: asked for "offscreen", Qt finds no + plugin and stops at a message box nobody can close, so every call hung + until its timeout. The export flags and --run open no window there, so + the default platform is what they need. + """ + env = dict(env, + HOME=str(home), + XDG_CONFIG_HOME=str(home / ".config"), + XDG_DATA_HOME=str(home / ".local" / "share")) + if not windows: + env["QT_QPA_PLATFORM"] = "offscreen" + return env + + def _run_qet(binary: str, args: list[str], timeout: int = 180, elements_dir: str | None = None, script: str | None = None, tail: int = 4000) -> dict: @@ -925,8 +963,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, raise ValueError(f"not an executable: {src}") with tempfile.TemporaryDirectory(prefix="qet-mcp-") as tmp: sandbox = Path(tmp) - exe = sandbox / f"qet-mcp-{os.getpid()}" - shutil.copy2(src, exe) + exe = _launch_executable(src, sandbox, os.name == "nt") home = sandbox / "home" (home / ".config").mkdir(parents=True) (home / ".local" / "share").mkdir(parents=True) @@ -943,11 +980,7 @@ def _run_qet(binary: str, args: list[str], timeout: int = 180, script_path = sandbox / "qet-mcp-edit.js" script_path.write_text(script, encoding="utf-8") args = ["--run", str(script_path), *args] - env = dict(os.environ, - HOME=str(home), - XDG_CONFIG_HOME=str(home / ".config"), - XDG_DATA_HOME=str(home / ".local" / "share"), - QT_QPA_PLATFORM="offscreen") + env = _launch_env(dict(os.environ), home, os.name == "nt") try: p = subprocess.run([str(exe), *args], env=env, timeout=timeout, capture_output=True, text=True) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 24c1e2718..3c1815ac0 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1902,6 +1902,45 @@ class PathPolicy(unittest.TestCase): f"{name} has no {arg!r} argument to guard") +class LaunchExecutable(unittest.TestCase): + """Windows cannot run a lone copy of QElectroTech (F065): its DLLs sit + beside the original. Everywhere else the private copy stays.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + base = Path(self.tmp.name) + self.src = base / "bin" / "qelectrotech" + self.src.parent.mkdir() + self.src.write_text("#!/bin/sh\nexit 0\n") + self.sandbox = base / "sandbox" + self.sandbox.mkdir() + + def tearDown(self): + self.tmp.cleanup() + + def test_windows_runs_the_original(self): + self.assertEqual(m._launch_executable(self.src, self.sandbox, True), self.src) + self.assertEqual(list(self.sandbox.iterdir()), [], "nothing is copied on Windows") + + def test_windows_keeps_its_own_qt_platform(self): + """The Windows packages have no offscreen plugin; asking for it + leaves QElectroTech stuck at a message box.""" + env = m._launch_env({"PATH": "x"}, self.sandbox, True) + self.assertNotIn("QT_QPA_PLATFORM", env) + self.assertEqual(env["PATH"], "x") + + def test_elsewhere_runs_offscreen(self): + env = m._launch_env({}, self.sandbox, False) + self.assertEqual(env["QT_QPA_PLATFORM"], "offscreen") + self.assertEqual(env["HOME"], str(self.sandbox)) + + def test_elsewhere_runs_a_private_copy(self): + exe = m._launch_executable(self.src, self.sandbox, False) + self.assertEqual(exe.parent, self.sandbox) + self.assertNotEqual(exe, self.src) + self.assertEqual(exe.read_text(), self.src.read_text()) + + class ScriptingDisabledHint(unittest.TestCase): """QElectroTech may refuse to run scripts at all, and says so in French. From ddc645174f7b8f896aea7ce29a6112b6b20914dc Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 15:17:16 +1300 Subject: [PATCH 32/45] Connect an AI assistant: say when Python is missing The configuration names "python" or "python3", or the Python the Windows installer can add. When none of those can run, the assistant fails with an error far from the cause, so the dialog now says so in bold, with what to do: tick "Python pour l'assistant IA" in the installer or install Python (Windows), or install Python 3 from the system's packages. Windows 10 and 11 put a python.exe in WindowsApps that only opens the Microsoft Store, so a PATH search alone reports Python where there is none. A result there is reported as possibly that shortcut. Backslashes are converted explicitly, not with QDir::fromNativeSeparators(), which leaves them alone off Windows and so could not be tested here. tst_aiassistantsetup: 17 cases (8 new: the bundled Python is taken without looking on PATH; none, python.org, the Store shortcut with either separator, and a Linux folder named WindowsApps). Each of the 4 new rules was removed in turn and the test failed. ctest 32/32. In the GUI, the same install layout shows the warning with no python3 on PATH and no warning with it. Co-Authored-By: Claude Opus 5.5 --- sources/ui/aiassistantdialog.cpp | 22 +++++++++++++ sources/ui/aiassistantsetup.cpp | 27 +++++++++++++-- sources/ui/aiassistantsetup.h | 18 +++++++++- tests/qttest/tst_aiassistantsetup.cpp | 47 +++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/sources/ui/aiassistantdialog.cpp b/sources/ui/aiassistantdialog.cpp index b9f7ac355..6d8542d7e 100644 --- a/sources/ui/aiassistantdialog.cpp +++ b/sources/ui/aiassistantdialog.cpp @@ -77,6 +77,28 @@ AiAssistantDialog::AiAssistantDialog(QWidget *parent) : layout->addWidget(missing); } + if (!m_paths.server.isEmpty() + && m_paths.python_status != AiAssistantSetup::PythonStatus::Found) { + QString text; + if (m_paths.python_status == AiAssistantSetup::PythonStatus::StoreShortcut) + text = tr("Python n'est peut-être pas installé. Seul le raccourci " + "« python » du Microsoft Store a été trouvé : sans Python, il " + "ouvre le Store au lieu de lancer le serveur."); + else + text = tr("Python est introuvable sur cet ordinateur (commande " + "« %1 »). Le serveur en a besoin.").arg(m_paths.python); + if (windows) + text += QLatin1Char(' ') + tr("Relancez l'installateur de QElectroTech et " + "cochez « Python pour l'assistant IA », ou " + "installez Python depuis python.org."); + else + text += QLatin1Char(' ') + tr("Installez Python 3 avec le gestionnaire " + "de paquets de votre système."); + auto *python = new QLabel(text, this); + python->setWordWrap(true); + layout->addWidget(python); + } + auto *form = new QFormLayout(); m_client = new QComboBox(this); m_client->addItem(QStringLiteral("Claude Desktop"), int(Client::ClaudeDesktop)); diff --git a/sources/ui/aiassistantsetup.cpp b/sources/ui/aiassistantsetup.cpp index d484fc6e0..6bb0923dc 100644 --- a/sources/ui/aiassistantsetup.cpp +++ b/sources/ui/aiassistantsetup.cpp @@ -22,6 +22,7 @@ #include #include #include +#include namespace { @@ -79,10 +80,13 @@ QList AiAssistantSetup::clients() @param application_dir : QCoreApplication::applicationDirPath() @param application_file : QCoreApplication::applicationFilePath() @param windows : true on Windows, where Python is "python", not "python3" + @param find_executable : looks a program up on PATH; empty means + QStandardPaths::findExecutable(). Given in tests. */ AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, const QString &application_file, - bool windows) + bool windows, + const ExecutableFinder &find_executable) { const QDir bin(application_dir); Paths paths; @@ -95,9 +99,28 @@ AiAssistantSetup::Paths AiAssistantSetup::detect(const QString &application_dir, if (windows) { const QString bundled = firstFile({ bin.filePath(QStringLiteral("../mcp/python/python.exe"))}); - if (!bundled.isEmpty()) + if (!bundled.isEmpty()) { paths.python = bundled; + paths.python_status = PythonStatus::Found; + return paths; + } } + + const ExecutableFinder find = find_executable + ? find_executable + : ExecutableFinder([](const QString &name) { + return QStandardPaths::findExecutable(name); }); + const QString found = find(paths.python); + if (found.isEmpty()) + paths.python_status = PythonStatus::Missing; + // Windows 10 and 11 put a python.exe in WindowsApps that only opens the + // Microsoft Store. A Python installed from the Store answers from there + // too, so this cannot tell the two apart: say which it may be. + else if (windows && QString(found).replace(QLatin1Char('\\'), QLatin1Char('/')) + .contains(QLatin1String("/WindowsApps/"), Qt::CaseInsensitive)) + paths.python_status = PythonStatus::StoreShortcut; + else + paths.python_status = PythonStatus::Found; return paths; } diff --git a/sources/ui/aiassistantsetup.h b/sources/ui/aiassistantsetup.h index 144a74020..799e91c63 100644 --- a/sources/ui/aiassistantsetup.h +++ b/sources/ui/aiassistantsetup.h @@ -21,6 +21,8 @@ #include #include +#include + /** @brief What an AI assistant needs to start QElectroTech's MCP server (misc/qet-mcp), and the configuration text each assistant expects. @@ -44,16 +46,30 @@ namespace AiAssistantSetup QList clients(); + /// Whether the Python the configuration names can actually run. + enum class PythonStatus { + Found, + Missing, + /// Only Windows' "python.exe" shortcut in WindowsApps, which opens + /// the Microsoft Store when Python is not installed. + StoreShortcut + }; + /// Where the pieces are. An empty server means none was found. struct Paths { QString server; ///< qet_mcp.py QString python; ///< the Python to run it with QString qet_binary; ///< this QElectroTech + PythonStatus python_status = PythonStatus::Missing; }; + /// Finds a program on PATH; QStandardPaths::findExecutable() by default. + using ExecutableFinder = std::function; + Paths detect(const QString &application_dir, const QString &application_file, - bool windows); + bool windows, + const ExecutableFinder &find_executable = ExecutableFinder()); QString configuration(Client client, const Paths &paths, diff --git a/tests/qttest/tst_aiassistantsetup.cpp b/tests/qttest/tst_aiassistantsetup.cpp index 09912077e..b7b566bcf 100644 --- a/tests/qttest/tst_aiassistantsetup.cpp +++ b/tests/qttest/tst_aiassistantsetup.cpp @@ -97,6 +97,53 @@ class TestAiAssistantSetup : public QObject QCOMPARE(p.python, QStringLiteral("python3")); } + void bundledPythonIsFoundWithoutLookingOnPath() + { + QTemporaryDir root; + touch(root.filePath(QStringLiteral("mcp/qet_mcp.py"))); + touch(root.filePath(QStringLiteral("mcp/python/python.exe"))); + QDir().mkpath(root.filePath(QStringLiteral("bin"))); + bool looked = false; + const auto p = AiAssistantSetup::detect(root.filePath(QStringLiteral("bin")), + root.filePath(QStringLiteral("bin/QElectroTech.exe")), true, + [&looked](const QString &) { looked = true; return QString(); }); + QCOMPARE(p.python_status, AiAssistantSetup::PythonStatus::Found); + QVERIFY(!looked); + } + + void pythonStatusFromPath_data() + { + QTest::addColumn("windows"); + QTest::addColumn("found"); + QTest::addColumn("status"); + using S = AiAssistantSetup::PythonStatus; + QTest::newRow("windows, none") << true << QString() << int(S::Missing); + QTest::newRow("windows, python.org") << true + << QStringLiteral("C:/Users/me/AppData/Local/Programs/Python/Python314/python.exe") << int(S::Found); + QTest::newRow("windows, Store shortcut") << true + << QStringLiteral("C:/Users/me/AppData/Local/Microsoft/WindowsApps/python.exe") << int(S::StoreShortcut); + QTest::newRow("windows, Store shortcut, backslashes") << true + << QStringLiteral("C:\\Users\\me\\AppData\\Local\\Microsoft\\WINDOWSAPPS\\python.exe") << int(S::StoreShortcut); + QTest::newRow("linux, none") << false << QString() << int(S::Missing); + QTest::newRow("linux, python3") << false << QStringLiteral("/usr/bin/python3") << int(S::Found); + QTest::newRow("linux, a folder named WindowsApps") << false + << QStringLiteral("/opt/WindowsApps/python3") << int(S::Found); + } + + void pythonStatusFromPath() + { + QFETCH(bool, windows); + QFETCH(QString, found); + QFETCH(int, status); + QTemporaryDir build; + QString asked; + const auto p = AiAssistantSetup::detect(build.path(), + build.filePath(QStringLiteral("qelectrotech")), windows, + [&](const QString &name) { asked = name; return found; }); + QCOMPARE(int(p.python_status), status); + QCOMPARE(asked, windows ? QStringLiteral("python") : QStringLiteral("python3")); + } + void noServerIsReportedAsEmpty() { QTemporaryDir build; From a0d5c4fa9ceaeeb0e0f3ea9a7447fb4ea170139d Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 15:38:20 +1300 Subject: [PATCH 33/45] Fill the project database from the file as it is read The project database -- behind parts lists, summary tables and the wiring list -- was filled by walking the built folios: every element and conductor object of every scene. Filling it from the document the project was just read from instead is what the database needs before a project could be opened without building every folio (qelectrotech-docker DB-FROM-XML-SCOPE.md). updateDB(document), called by readProjectXml(), fills diagram, diagram_info, element, element_info, terminal and conductor from the document, through the code the folios use: a BorderTitleBlock read from each folio's XML gives the title-block values and each element's grid cell, the embedded collection gives each definition, and the binders are shared with the live path. Shapes, texts and pictures still come from the folios (their boxes need fonts and pens). It falls back to the folios, saying why in the log, when the document does not carry what that needs: an item without a saved uuid (older files -- the folios derive them on load), a conductor naming its ends the older way, a %autonum folio number, a terminal showing its master's label, a missing or unbuildable definition. A symbol label computed from a formula is the one saved in the file, which QElectroTech writes as it computes it on every save. QET_DATABASE_FROM_FOLIOS=1 forces the folio path. tst_databasefromdocument saves every example once, then fills both ways and requires identical tables (24/24, filled from the document each time), and checks that an older file falls back with its reason. Red when the document path is made to write a wrong grid cell. Database phase of loading unchanged: industrial.qet 0.146 s vs 0.160 s, Polonez 0.040 s vs 0.038 s (median of 5). QETProject::projectWideProperties() is split out of updateDiagramsFolioData() so both fills use the same title-block context. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/dataBase/projectdatabase.cpp | 453 +++++++++++++++++++++- sources/dataBase/projectdatabase.h | 21 + sources/qetproject.cpp | 19 +- sources/qetproject.h | 1 + tests/qttest/CMakeLists.txt | 13 + tests/qttest/tst_databasefromdocument.cpp | 131 +++++++ 6 files changed, 627 insertions(+), 11 deletions(-) create mode 100644 tests/qttest/tst_databasefromdocument.cpp diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 865deab89..6e09e8739 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -19,6 +19,7 @@ #include "sqlreadonly.h" +#include "../bordertitleblock.h" #include "../diagram.h" #include "../diagramposition.h" #include "../elementprovider.h" @@ -32,8 +33,15 @@ #include "../qetgraphicsitem/terminal.h" #include "../qetinformation.h" #include "../qetproject.h" +#include "../qet.h" +#include "../ElementsCollection/xmlelementcollection.h" +#include "../properties/elementdata.h" #include +#include +#include + +#include #include #include #include @@ -125,6 +133,389 @@ void projectDataBase::updateDB() emit dataBaseUpdated(); } +/** + @brief projectDataBase::updateDB + updateDB() for a project just read from @p document. + + The diagram, diagram_info, element, element_info, terminal and conductor + tables are filled from the document itself when it carries everything + they need -- see populateFromDocument() -- and from the built folios + otherwise, as updateDB() does. Shapes, texts and pictures always come from + the built folios: their boxes need the fonts and pens a folio renders with. + + The two fills give the same tables (tst_databasefromdocument checks it on + the shipped examples). Reading the document instead of the folios is what + the database needs before a project can be opened without building every + folio; see DB-FROM-XML-SCOPE.md in qelectrotech-docker. +*/ +void projectDataBase::updateDB(const QDomDocument &document) +{ + if (m_update_blocked || !m_content_changed) { + updateDB(); + return; + } + //QET_DATABASE_FROM_FOLIOS=1 keeps the fill from the built folios: + //a way back should the two ever disagree, and what the test that + //says they do not compares against. + QString why; + if (qEnvironmentVariableIntValue("QET_DATABASE_FROM_FOLIOS") == 1) { + why = QStringLiteral("QET_DATABASE_FROM_FOLIOS is set"); + } else if (populateFromDocument(document, &why)) { + qInfo() << "Project database filled from the document"; + populateDrawingItemTables(); + m_content_changed = false; + emit dataBaseUpdated(); + return; + } + qInfo().noquote() << "Project database filled from the folios:" << why; + updateDB(); +} + +namespace { +struct DocumentTerminal +{ + QString uuid; + QString name; + bool master_label = false; +}; + +struct DocumentDefinition +{ + QString type; + QString sub_type; + QHash terminals; +}; + +struct DocumentElement +{ + QString uuid; + QString diagram_uuid; + QString pos; + QString type; + QString sub_type; + QVariant group; + DiagramContext informations; + QHash terminals; +}; + +struct DocumentConductor +{ + QString uuid; + QString diagram_uuid; + QString element1, terminal1, element2, terminal2; + QString text; +}; +} + +/** + @brief projectDataBase::populateFromDocument + Fill the diagram, diagram_info, element, element_info, terminal and + conductor tables from @p document, the project as read from its file, + with the same values the built folios give -- using the same code: + a BorderTitleBlock read from each folio's XML gives the title-block + values and each element's grid cell, the project's embedded collection + gives each element's definition, and the binders are shared. + + Nothing is written unless the whole document can be read this way. It + cannot -- and false is returned, for the caller to fill from the folios + instead -- when a folio, element or conductor carries no saved uuid (the + folios derive one on load), a conductor names its ends the older way, + a folio number uses %autonum, a conductor ends on a terminal that shows + its master's contact label, or an element's definition is missing or + not one the folios could build. A file saved by a current QElectroTech + carries everything else. + + A symbol label computed from a formula is the label saved in the file, + which QElectroTech writes as it computes it on every save. + @return true if the tables were filled +*/ +bool projectDataBase::populateFromDocument(const QDomDocument &document, QString *why) +{ + auto refuse = [why](const QString &reason) { + if (why) *why = reason; + return false; + }; + if (!m_project) { + return refuse(QStringLiteral("no project")); + } + + //The folios, in the order the project read them (readDiagramsXml()), + //each with its saved uuid -- the one the built folio has. + const QDomNodeList diagram_nodes = document.elementsByTagName(QStringLiteral("diagram")); + const QList diagrams = m_project->diagrams(); + if (diagram_nodes.size() != diagrams.size()) { + return refuse(QStringLiteral("the folios are not the document's")); + } + + const DiagramContext project_wide = m_project->projectWideProperties(); + //One border and title block read from each folio's XML in turn: + //only what it gives is kept. + BorderTitleBlock reader; + QList diagram_infos; + QList diagram_dates; + QList diagram_uuids; + QList elements; + QList conductors; + QSet element_uuids; + QHash definitions; //by element type + + //The project's own definitions by "embed://" path, which is how an + //element's type names them: one walk of the embedded collection + //rather than resolving an ElementsLocation per type. + QHash stored; + std::function walk = + [&stored, &walk](const QDomElement &category, const QString &path) { + for (QDomElement child = category.firstChildElement() ; + !child.isNull() ; child = child.nextSiblingElement()) { + const QString name = path + child.attribute(QStringLiteral("name")); + if (child.tagName() == QLatin1String("category")) { + walk(child, name + QLatin1Char('/')); + } else if (child.tagName() == QLatin1String("element")) { + stored.insert(name, child.firstChildElement(QStringLiteral("definition"))); + } + } + }; + if (auto collection = m_project->embeddedElementCollection()) { + walk(collection->root(), QStringLiteral("embed://")); + } + + for (int i = 0 ; i < diagram_nodes.size() ; ++i) + { + const QDomElement diagram_xml = diagram_nodes.at(i).toElement(); + const QUuid diagram_uuid(diagram_xml.attribute(QStringLiteral("uuid"))); + if (diagram_uuid.isNull() || diagram_uuid != diagrams.at(i)->uuid()) { + return refuse(QStringLiteral("a folio has no saved uuid")); + } + + BorderTitleBlock *border = &reader; + border->titleBlockFromXml(diagram_xml); + border->borderFromXml(diagram_xml); + if (border->folio().contains(QStringLiteral("%autonum"))) { + return refuse(QStringLiteral("a folio number uses %autonum")); + } + border->setFolioData(i + 1, int(diagram_nodes.size()), QString(), project_wide); + + QHash on_this_folio; //element uuid -> index in elements + for (const QDomElement &element_xml : QET::findInDomElement( + diagram_xml, QStringLiteral("elements"), QStringLiteral("element"))) + { + const QUuid uuid(element_xml.attribute(QStringLiteral("uuid"))); + if (uuid.isNull() || element_uuids.contains(uuid)) { + return refuse(QStringLiteral("an element has no saved uuid, or shares one")); + } + element_uuids.insert(uuid); + + //The definition the folio builds the element from, with the + //checks Element::buildFromXml() refuses an element on -- + //read once per type, as many elements share one. + const QString type = element_xml.attribute(QStringLiteral("type")); + auto known = definitions.constFind(type); + if (known == definitions.constEnd()) + { + const QDomElement definition = stored.value(type); + if (definition.isNull()) { + return refuse(QStringLiteral("an element's definition is not in the project")); + } + int number; + if (definition.tagName() != QLatin1String("definition") + || definition.attribute(QStringLiteral("type")) != QLatin1String("element") + || !QET::attributeIsAnInteger(definition, QStringLiteral("width"), &number) + || !QET::attributeIsAnInteger(definition, QStringLiteral("height"), &number) + || !QET::attributeIsAnInteger(definition, QStringLiteral("hotspot_x"), &number) + || !QET::attributeIsAnInteger(definition, QStringLiteral("hotspot_y"), &number) + || definition.firstChild().isNull()) { + return refuse(QStringLiteral("an element's definition cannot be built")); + } + DocumentDefinition read; + ElementData data; + data.fromXml(definition); + read.type = data.typeToString(); + read.sub_type = data.masterTypeToString(); + const QDomElement description = definition.firstChildElement(QStringLiteral("description")); + for (QDomElement t = description.firstChildElement(QStringLiteral("terminal")) ; + !t.isNull() ; t = t.nextSiblingElement(QStringLiteral("terminal"))) + { + const QUuid terminal_uuid(t.attribute(QStringLiteral("uuid"))); + if (terminal_uuid.isNull()) { + continue; + } + DocumentTerminal terminal; + terminal.uuid = terminal_uuid.toString(); + terminal.name = t.attribute(QStringLiteral("name")); + terminal.master_label = t.attribute(QStringLiteral("use_master_label")) == QLatin1String("true"); + read.terminals.insert(terminal_uuid, terminal); + } + known = definitions.insert(type, read); + } + + DocumentElement element; + element.uuid = uuid.toString(); + element.diagram_uuid = diagram_uuid.toString(); + element.pos = border->convertPosition( + QPointF(element_xml.attribute(QStringLiteral("x")).toDouble(), + element_xml.attribute(QStringLiteral("y")).toDouble())) + .toString(); + element.type = known->type; + element.sub_type = known->sub_type; + element.terminals = known->terminals; + const QUuid group = ItemGroups::read(element_xml); + element.group = group.isNull() ? QVariant() : QVariant(group.toString()); + element.informations.fromXml( + element_xml.firstChildElement(QStringLiteral("elementInformations")), + QStringLiteral("elementInformation")); + + on_this_folio.insert(uuid, int(elements.size())); + elements << element; + } + + //The conductors the folio builds, and only those: an end that is + //not found, a conductor from a terminal to itself and a second + //conductor between the same two terminals are all dropped on load. + QSet joined; + for (QDomElement conductor_xml : QET::findInDomElement( + diagram_xml, QStringLiteral("conductors"), QStringLiteral("conductor"))) + { + if (!Conductor::valideXml(conductor_xml)) { + continue; + } + const QUuid uuid(conductor_xml.attribute(QStringLiteral("uuid"))); + if (uuid.isNull() + || !conductor_xml.hasAttribute(QStringLiteral("element1")) + || !conductor_xml.hasAttribute(QStringLiteral("element2"))) { + return refuse(QStringLiteral("a conductor has no saved uuid, or names its ends the older way")); + } + QString ends[2][2]; + bool found = true; + for (int n = 0 ; n < 2 ; ++n) + { + const QString index = QString::number(n + 1); + const QUuid owner(conductor_xml.attribute(QStringLiteral("element") + index)); + const QUuid terminal(conductor_xml.attribute(QStringLiteral("terminal") + index)); + const int e = on_this_folio.value(owner, -1); + if (e < 0) { + found = false; + break; + } + if (!elements.at(e).terminals.contains(terminal)) { + //The folio would try the terminal's derived uuid; + //leave that to it. + return refuse(QStringLiteral("a conductor ends on a terminal not in its element's definition")); + } + const DocumentTerminal &t = elements.at(e).terminals.value(terminal); + if (t.master_label) { + return refuse(QStringLiteral("a conductor ends on a terminal showing its master's label")); + } + ends[n][0] = elements.at(e).uuid; + ends[n][1] = t.uuid; + } + if (!found) { + continue; + } + const QString a = ends[0][0] + ends[0][1], b = ends[1][0] + ends[1][1]; + if (a == b) { + continue; + } + const QString pair = a < b ? a + b : b + a; + if (joined.contains(pair)) { + continue; + } + joined.insert(pair); + + DocumentConductor conductor; + conductor.uuid = uuid.toString(); + conductor.diagram_uuid = diagram_uuid.toString(); + conductor.element1 = ends[0][0]; + conductor.terminal1 = ends[0][1]; + conductor.element2 = ends[1][0]; + conductor.terminal2 = ends[1][1]; + //ConductorProperties::fromXml()'s text, without reading the + //rest of the properties + conductor.text = conductor_xml.attribute(QStringLiteral("num")); + conductors << conductor; + } + + diagram_uuids << diagram_uuid; + diagram_infos << border->titleblockInformation(); + diagram_dates << border->date(); + } + + //Everything could be read: write it. + QSqlQuery query(m_data_base); + for (const QString &table : {QStringLiteral("diagram"), QStringLiteral("diagram_info"), + QStringLiteral("element"), QStringLiteral("element_info"), + QStringLiteral("conductor"), QStringLiteral("terminal")}) { + query.exec(QStringLiteral("DELETE FROM ") + table); + } + + for (int i = 0 ; i < diagram_uuids.size() ; ++i) + { + m_insert_diagram_query.bindValue(":uuid", diagram_uuids.at(i).toString()); + m_insert_diagram_query.bindValue(":pos", i + 1); + if (!m_insert_diagram_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument diagram insert error : " << m_insert_diagram_query.lastError(); + } + bindDiagramInfoValues(m_insert_diagram_info_query, diagram_uuids.at(i), + diagram_infos.at(i), diagram_dates.at(i)); + if (!m_insert_diagram_info_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument diagram_info insert error : " << m_insert_diagram_info_query.lastError(); + } + } + + for (const DocumentElement &element : std::as_const(elements)) + { + m_insert_elements_query.bindValue(QStringLiteral(":uuid"), element.uuid); + m_insert_elements_query.bindValue(QStringLiteral(":diagram_uuid"), element.diagram_uuid); + m_insert_elements_query.bindValue(QStringLiteral(":pos"), element.pos); + m_insert_elements_query.bindValue(QStringLiteral(":type"), element.type); + m_insert_elements_query.bindValue(QStringLiteral(":sub_type"), element.sub_type); + m_insert_elements_query.bindValue(QStringLiteral(":group_uuid"), element.group); + if (!m_insert_elements_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument element insert error : " << m_insert_elements_query.lastError(); + } + bindElementInfoValues(m_insert_element_info_query, element.uuid, element.informations, + element.informations[QStringLiteral("label")].toString()); + if (!m_insert_element_info_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument element_info insert error : " << m_insert_element_info_query.lastError(); + } + } + + QHash element_index; + for (int i = 0 ; i < elements.size() ; ++i) { + element_index.insert(elements.at(i).uuid, i); + } + for (const DocumentConductor &conductor : std::as_const(conductors)) + { + for (const auto &end : {std::make_pair(conductor.element1, conductor.terminal1), + std::make_pair(conductor.element2, conductor.terminal2)}) { + const DocumentElement &owner = elements.at(element_index.value(end.first)); + insertTerminal(end.second, end.first, + owner.terminals.value(QUuid(end.second)).name); + } + m_insert_conductor_query.bindValue(QStringLiteral(":uuid"), conductor.uuid); + m_insert_conductor_query.bindValue(QStringLiteral(":diagram_uuid"), conductor.diagram_uuid); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal1_uuid"), conductor.terminal1); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal1_element_uuid"), conductor.element1); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal2_uuid"), conductor.terminal2); + m_insert_conductor_query.bindValue(QStringLiteral(":terminal2_element_uuid"), conductor.element2); + m_insert_conductor_query.bindValue(QStringLiteral(":text"), conductor.text); + if (!m_insert_conductor_query.exec()) { + qDebug() << "projectDataBase::populateFromDocument conductor insert error : " << m_insert_conductor_query.lastError(); + } + } + + //While every folio is still built, their conductors are watched for + //property changes as populateConductorTable() does -- the one walk + //over the built folios left here. + for (Diagram *diagram : diagrams) { + for (Conductor *conductor : diagram->conductors()) { + if (conductor->terminal1->parentElement() && conductor->terminal2->parentElement()) { + watchConductor(conductor); + } + } + } + return true; +} + /** @brief projectDataBase::setUpdateBlocked @param blocked : whether updateDB() should skip the full rebuild @@ -1514,9 +1905,21 @@ void projectDataBase::populateConductorTable() */ void projectDataBase::insertTerminal(Terminal *terminal) { - m_insert_terminal_query.bindValue(":uuid", terminal->stableUuid().toString()); - m_insert_terminal_query.bindValue(":element_uuid", terminal->parentElement()->uuid().toString()); - m_insert_terminal_query.bindValue(":name", terminal->name()); + insertTerminal(terminal->stableUuid().toString(), + terminal->parentElement()->uuid().toString(), + terminal->name()); +} + +/** + @brief projectDataBase::insertTerminal + insertTerminal(Terminal *) from values rather than a live terminal. +*/ +void projectDataBase::insertTerminal(const QString &uuid, const QString &element_uuid, + const QString &name) +{ + m_insert_terminal_query.bindValue(":uuid", uuid); + m_insert_terminal_query.bindValue(":element_uuid", element_uuid); + m_insert_terminal_query.bindValue(":name", name); if (!m_insert_terminal_query.exec()) { qDebug() << "projectDataBase::insertTerminal insert error : " << m_insert_terminal_query.lastError(); } @@ -1733,11 +2136,48 @@ void projectDataBase::bindElementInfoValues(QSqlQuery &query, Element *element) } } +/** + @brief projectDataBase::bindElementInfoValues + bindElementInfoValues(QSqlQuery &, Element *) from values: the element's + information and the label to store for it. +*/ +void projectDataBase::bindElementInfoValues(QSqlQuery &query, const QString &element_uuid, + const DiagramContext &informations, + const QString &label) +{ + query.bindValue(QStringLiteral(":uuid"), element_uuid); + for (const auto &key : QETInformation::elementInfoKeys()) { + query.bindValue(QStringLiteral(":") + key, + key == QLatin1String("label") ? label + : informations[key].toString()); + } +} + void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram) { - query.bindValue(":uuid", diagram->uuid()); + bindDiagramInfoValues(query, diagram->uuid(), diagram->border_and_titleblock); +} + +/** + @brief projectDataBase::bindDiagramInfoValues + bindDiagramInfoValues(QSqlQuery &, Diagram *) from a folio's uuid and its + border and title block, which need not belong to a built folio. +*/ +void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const BorderTitleBlock &border) +{ + bindDiagramInfoValues(query, diagram_uuid, border.titleblockInformation(), border.date()); +} + +/** + @brief projectDataBase::bindDiagramInfoValues + The same from a title block's information and date. +*/ +void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const DiagramContext &infos, const QDate &date) +{ + query.bindValue(":uuid", diagram_uuid); - auto infos = diagram->border_and_titleblock.titleblockInformation(); for (auto key : QETInformation::diagramInfoKeys()) { if (key == "date") { @@ -1745,8 +2185,7 @@ void projectDataBase::bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram) //back: that text is the locale's short format, and where //it has a two-digit year (en_US "M/d/yy") toDate() reads //2010 back as 1910. - query.bindValue(QStringLiteral(":date"), - diagram->border_and_titleblock.date()); + query.bindValue(QStringLiteral(":date"), date); } else { auto value = infos.value(key); auto bind = key.prepend(":"); diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index 510d17eb7..67472af91 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -33,6 +33,10 @@ class Diagram; class Conductor; class Terminal; class QGraphicsItem; +class QDomDocument; +class BorderTitleBlock; +class DiagramContext; +class QDate; /** @brief The projectDataBase class @@ -50,6 +54,13 @@ class projectDataBase : public QObject virtual ~projectDataBase() override; void updateDB(); + /** + updateDB() for a project just read from @p document: the + diagram, element, terminal and conductor tables are filled + from the document when it carries everything they need (see + populateFromDocument()), from the built folios otherwise. + */ + void updateDB(const QDomDocument &document); /** Suppress the full rebuild performed by updateDB(). @@ -131,18 +142,28 @@ class projectDataBase : public QObject void populateDiagramInfoTable(); void populateConductorTable(); void populateDrawingItemTables(); + bool populateFromDocument(const QDomDocument &document, QString *why = nullptr); bool writeDrawingItem(QObject *object); void flushDrawingItems(); void forgetDrawingItem(QObject *object); void bindConductorValues(QSqlQuery &query, Conductor *conductor, Diagram *diagram); void watchConductor(Conductor *conductor); void insertTerminal(Terminal *terminal); + void insertTerminal(const QString &uuid, const QString &element_uuid, + const QString &name); void prepareQuery(); static QHash elementInfoToString( Element *elmt); void bindDiagramInfoValues(QSqlQuery &query, Diagram *diagram); + static void bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const BorderTitleBlock &border); + static void bindDiagramInfoValues(QSqlQuery &query, const QUuid &diagram_uuid, + const DiagramContext &infos, const QDate &date); static void bindElementValues(QSqlQuery &query, Element *element, Diagram *diagram); static void bindElementInfoValues(QSqlQuery &query, Element *element); + static void bindElementInfoValues(QSqlQuery &query, const QString &element_uuid, + const DiagramContext &informations, + const QString &label); private: QPointer m_project; diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index aae5a8bae..9cb4e3e33 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -1880,7 +1880,7 @@ void QETProject::readProjectXml(QDomDocument &xml_project) m_data_base.blockSignals(false); m_data_base.setUpdateBlocked(false); - m_data_base.updateDB(); + m_data_base.updateDB(xml_project); const qint64 database_ms = phase_timer.elapsed(); qInfo().nospace() @@ -2473,14 +2473,25 @@ bool QETProject::projectWasModified() Indique a chaque schema du projet quel est son numero de folio et combien de folio le projet contient. */ -void QETProject::updateDiagramsFolioData() +/** + @brief QETProject::projectWideProperties + @return the project's properties as every folio's title block sees them: + the user's project properties plus the project's title, path and file name. +*/ +DiagramContext QETProject::projectWideProperties() { - int total_folio = m_diagrams_list.count(); - DiagramContext project_wide_properties = m_project_properties; project_wide_properties.addValue("projecttitle", title()); project_wide_properties.addValue("projectpath", filePath()); project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).baseName()); + return project_wide_properties; +} + +void QETProject::updateDiagramsFolioData() +{ + int total_folio = m_diagrams_list.count(); + + const DiagramContext project_wide_properties = projectWideProperties(); for (int i = 0 ; i < total_folio ; ++ i) { diff --git a/sources/qetproject.h b/sources/qetproject.h index 35830912c..b30b07075 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -221,6 +221,7 @@ class QETProject : public QObject bool projectWasModified(); bool projectOptionsWereModified(); DiagramContext projectProperties(); + DiagramContext projectWideProperties(); void setProjectProperties(const DiagramContext &); QUndoStack* undoStack() {return m_undo_stack;} diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 1d37ab8d8..de6de9667 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -344,6 +344,19 @@ if(QET_HAS_SCRIPTING) target_compile_definitions(tst_scriptconductoruuid PRIVATE "QET_TEST_BINARY_PATH=\"$\"") + # The project database filled from the file as it is read holds exactly + # what it holds filled from the built folios (QET_DATABASE_FROM_FOLIOS=1), + # on every example saved once; an older file falls back, saying why. + add_executable( + tst_databasefromdocument + tst_databasefromdocument.cpp) + add_test(NAME tst_databasefromdocument COMMAND tst_databasefromdocument) + add_dependencies(tst_databasefromdocument qelectrotech) + target_link_libraries(tst_databasefromdocument PRIVATE Qt::Test) + target_compile_definitions(tst_databasefromdocument PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # The project database keeps a folio's date with its century: 741.qet's # 2010-09-21, read through project_summary_view under LC_ALL=en_US.UTF-8. add_executable( diff --git a/tests/qttest/tst_databasefromdocument.cpp b/tests/qttest/tst_databasefromdocument.cpp new file mode 100644 index 000000000..f375dd2da --- /dev/null +++ b/tests/qttest/tst_databasefromdocument.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// A project's database, filled from the file as it is read, holds exactly +// what the same database filled from the built folios holds. Every example +// is saved once (so that it carries the uuids a current QElectroTech +// writes), then opened twice through the real binary's --run: once as is, +// once with QET_DATABASE_FROM_FOLIOS=1, and the six tables compared. +class tst_databasefromdocument : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + int m_run = 0; + + QProcessEnvironment env() + { + const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++)); + QDir().mkpath(home); + QProcessEnvironment e = QProcessEnvironment::systemEnvironment(); + e.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + e.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + e.insert(QStringLiteral("HOME"), home); + e.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + e.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + e.insert(QStringLiteral("TMPDIR"), m_dir.path()); + e.remove(QStringLiteral("QET_DATABASE_FROM_FOLIOS")); + return e; + } + + QString run(const QStringList &args, bool from_folios = false) + { + QProcessEnvironment e = env(); + if (from_folios) + e.insert(QStringLiteral("QET_DATABASE_FROM_FOLIOS"), QStringLiteral("1")); + QProcess proc; + proc.setProcessEnvironment(e); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), args); + if (!proc.waitForFinished(180000)) return {}; + return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError()); + } + + // The six tables, each as a sorted list of its rows, and which way the + // database was filled. + QJsonObject dump(const QString &project, bool from_folios, QString *how) + { + const QString out = run({QStringLiteral("--run"), m_dir.filePath(QStringLiteral("dump.js")), + project}, from_folios); + QJsonObject tables; + for (const QString &line : out.split(QLatin1Char('\n'))) { + if (line.contains(QStringLiteral("Project database filled"))) + *how = line.mid(line.indexOf(QStringLiteral("Project database filled"))); + const int i = line.indexOf(QStringLiteral("DUMP ")); + if (i >= 0) { + const QJsonObject raw = QJsonDocument::fromJson(line.mid(i + 5).toUtf8()).object(); + for (auto it = raw.begin(); it != raw.end(); ++it) { + QStringList rows; + for (const QJsonValue &row : it.value().toArray()) + rows << QString::fromUtf8(QJsonDocument(row.toObject()).toJson(QJsonDocument::Compact)); + rows.sort(); + tables.insert(it.key(), QJsonArray::fromStringList(rows)); + } + } + } + return tables; + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + QFile js(m_dir.filePath(QStringLiteral("dump.js"))); + QVERIFY(js.open(QIODevice::WriteOnly)); + js.write("var out = {};\n" + "['diagram', 'diagram_info', 'element', 'element_info', 'terminal', 'conductor']" + ".forEach(function (t) { out[t] = qet.query('SELECT * FROM ' + t); });\n" + "qet.log('DUMP ' + JSON.stringify(out));\n"); + } + + void sameTablesBothWays_data() + { + QTest::addColumn("project"); + const QDir examples(QStringLiteral(QET_EXAMPLES_DIR)); + for (const QString &f : examples.entryList({QStringLiteral("*.qet")}, QDir::Files, QDir::Name)) + QTest::newRow(f.toUtf8().constData()) << examples.filePath(f); + } + + void sameTablesBothWays() + { + QFETCH(QString, project); + const QString saved = m_dir.filePath(QStringLiteral("saved%1.qet").arg(m_run)); + run({QStringLiteral("--resave"), project, saved}); + QVERIFY2(QFile::exists(saved), "--resave failed"); + + QString how_document, how_folios; + const QJsonObject document = dump(saved, false, &how_document); + const QJsonObject folios = dump(saved, true, &how_folios); + QVERIFY2(how_document == QLatin1String("Project database filled from the document"), + qPrintable(how_document)); + QVERIFY2(how_folios.contains(QStringLiteral("QET_DATABASE_FROM_FOLIOS")), qPrintable(how_folios)); + QCOMPARE(document.keys().size(), 6); + for (const QString &table : folios.keys()) { + const QJsonArray a = document.value(table).toArray(), b = folios.value(table).toArray(); + QVERIFY2(a == b, qPrintable(QStringLiteral("%1: %2 rows from the document, %3 from the folios") + .arg(table).arg(a.size()).arg(b.size()))); + } + } + + // A file whose items carry no saved uuid is filled from the folios, + // which work those uuids out as they are built, and says why. + void olderFileFallsBack() + { + QString how; + dump(QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), false, &how); + QCOMPARE(how, QStringLiteral("Project database filled from the folios: a folio has no saved uuid")); + } +}; + +QTEST_APPLESS_MAIN(tst_databasefromdocument) + +#include "tst_databasefromdocument.moc" From 42fab5ad78266df9bcc2bfb99223a47ff4a2c429 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 15:56:32 +1300 Subject: [PATCH 34/45] Fix title blocks cutting project file names at the first dot A project saved as "my.project.qet" showed "my" in every title block cell using %{projectfilename}, and "my" again in %{savedfilename} after a save. QETProject used QFileInfo::baseName(), which stops at the first "."; completeBaseName() strips only the ".qet" suffix. Same fix as #725 made for the PDF export's file name. Verified headlessly on examples/industrial.qet copied to my.project.qet, with a title block cell set to %{projectfilename}: --export-svg shows "my" on master and "my.project" with this change, on all 50 folios. For %{savedfilename}, a --run script calling qet.save("") twice writes "my" on master and "my.project" with this change. ctest 32/32. Not changed: write() updates the saved* variables after the file is written, so the values stored in a file are those of the save before. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/qetproject.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index aae5a8bae..22e926509 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -580,7 +580,7 @@ void QETProject::setFilePath(const QString &filepath) m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); @@ -1428,7 +1428,7 @@ QETResult QETProject::write() m_project_properties.addValue("saveddate-us", QDate::currentDate().toString("yyyy-MM-dd")); m_project_properties.addValue("saveddate-eu", QDate::currentDate().toString("dd-MM-yyyy")); m_project_properties.addValue("savedtime", QDateTime::currentDateTime().toString("HH:mm")); - m_project_properties.addValue("savedfilename", QFileInfo(filePath()).baseName()); + m_project_properties.addValue("savedfilename", QFileInfo(filePath()).completeBaseName()); m_project_properties.addValue("savedfilepath", filePath()); emit projectInformationsChanged(this); @@ -2480,7 +2480,7 @@ void QETProject::updateDiagramsFolioData() DiagramContext project_wide_properties = m_project_properties; project_wide_properties.addValue("projecttitle", title()); project_wide_properties.addValue("projectpath", filePath()); - project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).baseName()); + project_wide_properties.addValue("projectfilename", QFileInfo(filePath()).completeBaseName()); for (int i = 0 ; i < total_folio ; ++ i) { From b1abab1241cb2330d74a020db9434dec9c1e637b Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 16:08:45 +1300 Subject: [PATCH 35/45] Work out formula labels and wire texts from the file, with the folios' code The first fill from the document stored the label and wire text saved in the file. QElectroTech saves what it last worked out, not what the formula gives now: K%total-%id saved as K1-1 on a 3-folio project is K3-1 on the folios, and the same for a wire's formula. So the document fill disagreed with the folio fill on any file whose folios had been added or moved. AssignVariables now works from a FormulaContext: the folio's number, index, total, plant, location, title-block and project variables, and the element's grid cell and prefix or the wire's four properties. The Diagram overload fills one from a built folio, the database fills one from the file, and both call the same evaluation. Also brought in line with Diagram::fromXml(): an element Element::valideXml() rejects is skipped with its wires; a folio where two elements number their terminals alike (Element::fromXml() refuses one, by geometry), a frozen formula wire text, and the older sequential-number attributes are left to the folios. Each folio's border now starts from the defaults a new Diagram has, not the previous folio's values. tst_databasefromdocument: formulasAreWorkedOut (every variable kind, stale saved values), unbuiltElementIsLeftOut, clashingTerminalIdsFallBack. Each fails with its fix removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/autoNum/assignvariables.cpp | 153 ++++++++++-------- sources/autoNum/assignvariables.h | 38 ++++- sources/dataBase/projectdatabase.cpp | 120 ++++++++++++-- tests/qttest/CMakeLists.txt | 2 +- tests/qttest/tst_databasefromdocument.cpp | 186 ++++++++++++++++++++-- 5 files changed, 405 insertions(+), 94 deletions(-) diff --git a/sources/autoNum/assignvariables.cpp b/sources/autoNum/assignvariables.cpp index c8059bbf2..1cd48953e 100644 --- a/sources/autoNum/assignvariables.cpp +++ b/sources/autoNum/assignvariables.cpp @@ -220,12 +220,56 @@ namespace autonum Diagram *diagram, const Element *elmt, const Conductor *cndr) + { + if (!diagram) { + return formula; + } + + FormulaContext context; + const BorderTitleBlock &border = diagram->border_and_titleblock; + context.folio = border.folio(); + context.folio_index = diagram->folioIndex(); + context.folio_total = border.folioTotal(); + context.plant = border.plant(); + context.locmach = border.locmach(); + context.title_block_fields = border.additionalFields(); + context.project_properties = diagram->project()->projectProperties(); + if (elmt) + { + context.has_element = true; + context.element_position = diagram->convertPosition(elmt->scenePos()); + context.element_prefix = elmt->getPrefix(); + } + if (cndr) + { + context.has_conductor = true; + context.wire_function = cndr->properties().m_function; + context.wire_tension_protocol = cndr->properties().m_tension_protocol; + context.wire_color = cndr->properties().m_wire_color; + context.wire_section = cndr->properties().m_wire_section; + } + return formulaToLabel(std::move(formula), seqStruct, context); + } + + /** + @brief AssignVariables::formulaToLabel + Return the formula with its variables assigned from @p context, + which describes a folio and the element or conductor the formula + belongs to without needing either to be built. + @param formula - the formula to work + @param seqStruct - struct where is stocked int values + (struct is passed as a reference + and modified by this static method) + @param context - what the variables are read from + @return the string with variable assigned. + */ + QString AssignVariables::formulaToLabel(QString formula, + sequentialNumbers &seqStruct, + const FormulaContext &context) { AssignVariables av(std::move(formula), seqStruct, - diagram, - elmt, - cndr); + context); seqStruct = av.m_seq_struct; return av.m_assigned_label; } @@ -347,76 +391,54 @@ namespace autonum AssignVariables::AssignVariables(const QString& formula, const sequentialNumbers& seqStruct, - Diagram *diagram, - const Element *elmt, - const Conductor *cndr): - m_diagram(diagram), + const FormulaContext &context): + m_context(context), m_arg_formula(formula), m_assigned_label(formula), - m_seq_struct(seqStruct), - m_element(elmt), - m_conductor(cndr) + m_seq_struct(seqStruct) { - if (m_diagram) + m_assigned_label.replace("%F", m_context.folio); + m_assigned_label.replace("%f", + QString::number(m_context.folio_index+1)); + m_assigned_label.replace("%id", + QString::number(m_context.folio_index+1)); + m_assigned_label.replace("%total", + QString::number(m_context.folio_total)); + m_assigned_label.replace("%M", m_context.plant); + m_assigned_label.replace("%LM", m_context.locmach); + + QSettings settings; + if (m_context.has_element) { - m_assigned_label.replace("%F", - m_diagram - -> border_and_titleblock - .folio()); - m_assigned_label.replace("%f", - QString::number( - m_diagram - ->folioIndex()+1)); - m_assigned_label.replace("%id", - QString::number( - m_diagram - ->folioIndex()+1)); - m_assigned_label.replace("%total", - QString::number( - m_diagram - ->border_and_titleblock - .folioTotal())); - m_assigned_label.replace("%M", - m_diagram - -> border_and_titleblock - .plant()); - m_assigned_label.replace("%LM", - m_diagram - -> border_and_titleblock - .locmach()); - - QSettings settings; - if (m_element) - { - if (settings.value("border-columns_0", true).toBool()){ - m_assigned_label.replace("%c", QString::number(m_diagram->convertPosition(m_element->scenePos()).number() - 1)); - }else{ - m_assigned_label.replace("%c", QString::number(m_diagram->convertPosition(m_element->scenePos()).number())); - } - m_assigned_label.replace("%l", m_diagram->convertPosition(m_element->scenePos()).letter()); - m_assigned_label.replace("%prefix", m_element->getPrefix()); + if (settings.value("border-columns_0", true).toBool()){ + m_assigned_label.replace("%c", QString::number(m_context.element_position.number() - 1)); + }else{ + m_assigned_label.replace("%c", QString::number(m_context.element_position.number())); } - - if (m_conductor) - { - m_assigned_label.replace("%wf", cndr->properties().m_function); - m_assigned_label.replace("%wv", cndr->properties().m_tension_protocol); - m_assigned_label.replace("%wc", cndr->properties().m_wire_color); - m_assigned_label.replace("%ws", cndr->properties().m_wire_section); - } - - assignTitleBlockVar(); - assignProjectVar(); - assignSequence(); + m_assigned_label.replace("%l", m_context.element_position.letter()); + m_assigned_label.replace("%prefix", m_context.element_prefix); } + + if (m_context.has_conductor) + { + m_assigned_label.replace("%wf", m_context.wire_function); + m_assigned_label.replace("%wv", m_context.wire_tension_protocol); + m_assigned_label.replace("%wc", m_context.wire_color); + m_assigned_label.replace("%ws", m_context.wire_section); + } + + assignTitleBlockVar(); + assignProjectVar(); + assignSequence(); } void AssignVariables::assignTitleBlockVar() { - for (int i = 0; i < m_diagram->border_and_titleblock.additionalFields().count(); i++) + DiagramContext fields = m_context.title_block_fields; + for (int i = 0; i < fields.count(); i++) { - QString folio_variable = m_diagram->border_and_titleblock.additionalFields().keys().at(i); - QVariant folio_value = m_diagram->border_and_titleblock.additionalFields().operator [](folio_variable); + QString folio_variable = fields.keys().at(i); + QVariant folio_value = fields[folio_variable]; if (m_assigned_label.contains(folio_variable)) { m_assigned_label.replace("%{" + folio_variable + "}", folio_value.toString()); @@ -427,10 +449,11 @@ namespace autonum void AssignVariables::assignProjectVar() { - for (int i = 0; i < m_diagram->project()->projectProperties().count(); i++) + DiagramContext properties = m_context.project_properties; + for (int i = 0; i < properties.count(); i++) { - QString folio_variable = m_diagram->project()->projectProperties().keys().at(i); - QVariant folio_value = m_diagram->project()->projectProperties().operator [](folio_variable); + QString folio_variable = properties.keys().at(i); + QVariant folio_value = properties[folio_variable]; if (m_assigned_label.contains(folio_variable)) { m_assigned_label.replace("%{" + folio_variable + "}", folio_value.toString()); diff --git a/sources/autoNum/assignvariables.h b/sources/autoNum/assignvariables.h index 5fffdf43f..255ad6003 100644 --- a/sources/autoNum/assignvariables.h +++ b/sources/autoNum/assignvariables.h @@ -18,6 +18,7 @@ #ifndef ASSIGNVARIABLES_H #define ASSIGNVARIABLES_H #include "../diagramcontext.h" +#include "../diagramposition.h" #include "numerotationcontext.h" #include @@ -57,6 +58,36 @@ namespace autonum QStringList alpha; }; + /** + @brief The FormulaContext struct + Everything a formula's variables are read from: the folio it is on + and, for an element's or a conductor's formula, that item. + AssignVariables::formulaToLabel() fills one from a built folio; the + project database fills one from the project's file. + */ + struct FormulaContext + { + /// border_and_titleblock.folio(), folioIndex(), folioTotal()... + QString folio; + int folio_index = 0; + int folio_total = 0; + QString plant; + QString locmach; + /// the title block's additional fields and the project's properties + DiagramContext title_block_fields; + DiagramContext project_properties; + /// an element's grid cell (%c, %l) and prefix + bool has_element = false; + DiagramPosition element_position; + QString element_prefix; + /// a conductor's %wf, %wv, %wc and %ws + bool has_conductor = false; + QString wire_function; + QString wire_tension_protocol; + QString wire_color; + QString wire_section; + }; + /** @brief The AssignVariables class This class assign variable of a formula string. @@ -66,21 +97,20 @@ namespace autonum { public: static QString formulaToLabel (QString formula, sequentialNumbers &seqStruct, Diagram *diagram, const Element *elmt = nullptr, const Conductor *cndr = nullptr); + static QString formulaToLabel (QString formula, sequentialNumbers &seqStruct, const FormulaContext &context); static QString replaceVariable (const QString &formula, const DiagramContext &dc); static QString genericXref (const Element *element); private: - AssignVariables(const QString& formula, const sequentialNumbers& seqStruct , Diagram *diagram, const Element *elmt = nullptr, const Conductor *cndr = nullptr); + AssignVariables(const QString& formula, const sequentialNumbers& seqStruct, const FormulaContext &context); void assignTitleBlockVar(); void assignProjectVar(); void assignSequence(); - Diagram *m_diagram = nullptr; + const FormulaContext &m_context; QString m_arg_formula; QString m_assigned_label; sequentialNumbers m_seq_struct; - const Element *m_element = nullptr; - const Conductor *m_conductor = nullptr; }; void setSequentialToList(QStringList &list, NumerotationContext &nc, const QString& type); diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 6e09e8739..bea454bd4 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -19,6 +19,8 @@ #include "sqlreadonly.h" +#include "../autoNum/assignvariables.h" +#include "../borderproperties.h" #include "../bordertitleblock.h" #include "../diagram.h" #include "../diagramposition.h" @@ -34,6 +36,7 @@ #include "../qetinformation.h" #include "../qetproject.h" #include "../qet.h" +#include "../titleblockproperties.h" #include "../ElementsCollection/xmlelementcollection.h" #include "../properties/elementdata.h" @@ -195,9 +198,24 @@ struct DocumentElement QString sub_type; QVariant group; DiagramContext informations; + QString label; QHash terminals; }; + //The sequential values an element or a conductor was saved with, as + //Element::fromXml() and Conductor::fromXml() read them -- false for + //the attributes files written before carry. +bool readSequence(const QDomElement &item, autonum::sequentialNumbers *sequence) +{ + for (const char *name : {"sequ_1", "sequf_1", "seqt_1", "seqtf_1", "seqh_1"}) { + if (item.hasAttribute(QLatin1String(name))) { + return false; + } + } + sequence->fromXml(item.firstChildElement(QStringLiteral("sequentialNumbers"))); + return true; +} + struct DocumentConductor { QString uuid; @@ -221,12 +239,17 @@ struct DocumentConductor instead -- when a folio, element or conductor carries no saved uuid (the folios derive one on load), a conductor names its ends the older way, a folio number uses %autonum, a conductor ends on a terminal that shows - its master's contact label, or an element's definition is missing or - not one the folios could build. A file saved by a current QElectroTech - carries everything else. + its master's contact label, an element's definition is missing or + not one the folios could build, two elements on a folio number their + terminals alike, or sequential numbers are saved as the attributes + older files carry. A file saved by a current QElectroTech carries + everything else. - A symbol label computed from a formula is the label saved in the file, - which QElectroTech writes as it computes it on every save. + A label or a conductor text made from a formula is worked out again, + as the folios do, with the same AssignVariables code: the one saved + in the file is what the formula gave when it was saved, and a folio + added or moved since changes it. A frozen conductor text is left to the + folios, which work it out part-way through loading. @return true if the tables were filled */ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString *why) @@ -248,6 +271,7 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString } const DiagramContext project_wide = m_project->projectWideProperties(); + const DiagramContext project_properties = m_project->projectProperties(); //One border and title block read from each folio's XML in turn: //only what it gives is kept. BorderTitleBlock reader; @@ -288,6 +312,9 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString } BorderTitleBlock *border = &reader; + //As a new Diagram's border starts, before initFromXml() reads it + border->importBorder(BorderProperties()); + border->importTitleBlock(TitleBlockProperties()); border->titleBlockFromXml(diagram_xml); border->borderFromXml(diagram_xml); if (border->folio().contains(QStringLiteral("%autonum"))) { @@ -295,16 +322,49 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString } border->setFolioData(i + 1, int(diagram_nodes.size()), QString(), project_wide); + //What a formula on this folio is worked out from, as + //AssignVariables::formulaToLabel() reads it off a built folio. + autonum::FormulaContext folio_context; + folio_context.folio = border->folio(); + folio_context.folio_index = i; + folio_context.folio_total = border->folioTotal(); + folio_context.plant = border->plant(); + folio_context.locmach = border->locmach(); + folio_context.title_block_fields = border->additionalFields(); + folio_context.project_properties = project_properties; + QHash on_this_folio; //element uuid -> index in elements - for (const QDomElement &element_xml : QET::findInDomElement( + QSet terminal_ids; //the older terminal ids used so far + for (QDomElement element_xml : QET::findInDomElement( diagram_xml, QStringLiteral("elements"), QStringLiteral("element"))) { + //Skipped by Diagram::fromXml() as well + if (!Element::valideXml(element_xml)) { + continue; + } const QUuid uuid(element_xml.attribute(QStringLiteral("uuid"))); if (uuid.isNull() || element_uuids.contains(uuid)) { return refuse(QStringLiteral("an element has no saved uuid, or shares one")); } element_uuids.insert(uuid); + //Element::fromXml() refuses an element whose terminals are + //numbered like those of one read before it on the folio; + //which of them it matches needs their geometry, so leave any + //such folio to the folios. A current file numbers them + //across the folio. + QSet ids; + for (QDomElement t : QET::findInDomElement( + element_xml, QStringLiteral("terminals"), QStringLiteral("terminal"))) { + if (Terminal::valideXml(t)) { + ids.insert(t.attribute(QStringLiteral("id")).toInt()); + } + } + if (terminal_ids.intersects(ids)) { + return refuse(QStringLiteral("two elements on a folio number their terminals alike")); + } + terminal_ids.unite(ids); + //The definition the folio builds the element from, with the //checks Element::buildFromXml() refuses an element on -- //read once per type, as many elements share one. @@ -351,10 +411,10 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString DocumentElement element; element.uuid = uuid.toString(); element.diagram_uuid = diagram_uuid.toString(); - element.pos = border->convertPosition( - QPointF(element_xml.attribute(QStringLiteral("x")).toDouble(), - element_xml.attribute(QStringLiteral("y")).toDouble())) - .toString(); + DiagramPosition position = border->convertPosition( + QPointF(element_xml.attribute(QStringLiteral("x")).toDouble(), + element_xml.attribute(QStringLiteral("y")).toDouble())); + element.pos = position.toString(); element.type = known->type; element.sub_type = known->sub_type; element.terminals = known->terminals; @@ -363,6 +423,21 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString element.informations.fromXml( element_xml.firstChildElement(QStringLiteral("elementInformations")), QStringLiteral("elementInformation")); + //Element::actualLabel() + const QString formula = element.informations.value(QStringLiteral("formula")).toString(); + if (formula.isEmpty()) { + element.label = element.informations.value(QStringLiteral("label")).toString(); + } else { + autonum::sequentialNumbers sequence; + if (!readSequence(element_xml, &sequence)) { + return refuse(QStringLiteral("an element's sequential numbers are saved the older way")); + } + autonum::FormulaContext context = folio_context; + context.has_element = true; + context.element_position = position; + context.element_prefix = element_xml.attribute(QStringLiteral("prefix")); + element.label = autonum::AssignVariables::formulaToLabel(formula, sequence, context); + } on_this_folio.insert(uuid, int(elements.size())); elements << element; @@ -429,8 +504,27 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString conductor.element2 = ends[1][0]; conductor.terminal2 = ends[1][1]; //ConductorProperties::fromXml()'s text, without reading the - //rest of the properties - conductor.text = conductor_xml.attribute(QStringLiteral("num")); + //rest of the properties -- or, as Conductor::refreshText() + //makes it, what its formula gives. + const QString formula = conductor_xml.attribute(QStringLiteral("formula")); + if (formula.isEmpty()) { + conductor.text = conductor_xml.attribute(QStringLiteral("num")); + } else { + autonum::sequentialNumbers sequence; + if (conductor_xml.attribute(QStringLiteral("freezeLabel")) == QLatin1String("true")) { + return refuse(QStringLiteral("a conductor's text made from a formula is frozen")); + } + if (!readSequence(conductor_xml, &sequence)) { + return refuse(QStringLiteral("a conductor's sequential numbers are saved the older way")); + } + autonum::FormulaContext context = folio_context; + context.has_conductor = true; + context.wire_function = conductor_xml.attribute(QStringLiteral("function")); + context.wire_tension_protocol = conductor_xml.attribute(QStringLiteral("tension_protocol")); + context.wire_color = conductor_xml.attribute(QStringLiteral("conductor_color")); + context.wire_section = conductor_xml.attribute(QStringLiteral("conductor_section")); + conductor.text = autonum::AssignVariables::formulaToLabel(formula, sequence, context); + } conductors << conductor; } @@ -473,7 +567,7 @@ bool projectDataBase::populateFromDocument(const QDomDocument &document, QString qDebug() << "projectDataBase::populateFromDocument element insert error : " << m_insert_elements_query.lastError(); } bindElementInfoValues(m_insert_element_info_query, element.uuid, element.informations, - element.informations[QStringLiteral("label")].toString()); + element.label); if (!m_insert_element_info_query.exec()) { qDebug() << "projectDataBase::populateFromDocument element_info insert error : " << m_insert_element_info_query.lastError(); } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index de6de9667..d324fac74 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -352,7 +352,7 @@ if(QET_HAS_SCRIPTING) tst_databasefromdocument.cpp) add_test(NAME tst_databasefromdocument COMMAND tst_databasefromdocument) add_dependencies(tst_databasefromdocument qelectrotech) - target_link_libraries(tst_databasefromdocument PRIVATE Qt::Test) + target_link_libraries(tst_databasefromdocument PRIVATE Qt::Test Qt::Xml) target_compile_definitions(tst_databasefromdocument PRIVATE "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") diff --git a/tests/qttest/tst_databasefromdocument.cpp b/tests/qttest/tst_databasefromdocument.cpp index f375dd2da..d2febebf4 100644 --- a/tests/qttest/tst_databasefromdocument.cpp +++ b/tests/qttest/tst_databasefromdocument.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -74,6 +75,44 @@ class tst_databasefromdocument : public QObject return tables; } + // tremie_vibrante.qet saved once, as a document to change + QDomDocument resaved(const QString &saved) + { + run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved}); + QFile file(saved); + QDomDocument document; + if (file.open(QIODevice::ReadOnly)) + document.setContent(&file); + return document; + } + + static bool write(const QString &path, const QDomDocument &document) + { + QFile file(path); + return file.open(QIODevice::WriteOnly | QIODevice::Truncate) + && file.write(document.toByteArray()) > 0; + } + + // Both fills of @p saved, which must come from the document, agree. + void compareBothWays(const QString &saved) + { + QString how_document, how_folios; + const QJsonObject document = dump(saved, false, &how_document); + const QJsonObject folios = dump(saved, true, &how_folios); + QVERIFY2(how_document == QLatin1String("Project database filled from the document"), + qPrintable(how_document)); + QVERIFY2(how_folios.contains(QStringLiteral("QET_DATABASE_FROM_FOLIOS")), qPrintable(how_folios)); + QCOMPARE(document.keys().size(), 6); + for (const QString &table : folios.keys()) { + const QJsonArray a = document.value(table).toArray(), b = folios.value(table).toArray(); + QVERIFY2(a == b, qPrintable(QStringLiteral("%1: %2 rows from the document, %3 from the folios") + .arg(table).arg(a.size()).arg(b.size()))); + } + m_last = document; + } + + QJsonObject m_last; // the document's tables, from compareBothWays() + private slots: void initTestCase() { @@ -101,19 +140,144 @@ private slots: const QString saved = m_dir.filePath(QStringLiteral("saved%1.qet").arg(m_run)); run({QStringLiteral("--resave"), project, saved}); QVERIFY2(QFile::exists(saved), "--resave failed"); + compareBothWays(saved); + } - QString how_document, how_folios; - const QJsonObject document = dump(saved, false, &how_document); - const QJsonObject folios = dump(saved, true, &how_folios); - QVERIFY2(how_document == QLatin1String("Project database filled from the document"), - qPrintable(how_document)); - QVERIFY2(how_folios.contains(QStringLiteral("QET_DATABASE_FROM_FOLIOS")), qPrintable(how_folios)); - QCOMPARE(document.keys().size(), 6); - for (const QString &table : folios.keys()) { - const QJsonArray a = document.value(table).toArray(), b = folios.value(table).toArray(); - QVERIFY2(a == b, qPrintable(QStringLiteral("%1: %2 rows from the document, %3 from the folios") - .arg(table).arg(a.size()).arg(b.size()))); + // A label or a conductor text made from a formula is what the formula + // gives now, not what it gave when the file was saved: every folio's + // first element and first conductor is given a formula using each kind + // of variable, and a saved label no formula gives. + void formulasAreWorkedOut() + { + const QString saved = m_dir.filePath(QStringLiteral("formulas-saved.qet")); + run({QStringLiteral("--resave"), QStringLiteral(QET_EXAMPLES_DIR "/tremie_vibrante.qet"), saved}); + QFile file(saved); + QVERIFY(file.open(QIODevice::ReadOnly)); + QDomDocument document; + QVERIFY(document.setContent(&file)); + file.close(); + + auto property = [&document](QDomElement parent, const QString &name, const QString &value) { + QDomElement properties = parent.firstChildElement(QStringLiteral("properties")); + if (properties.isNull()) + properties = parent.appendChild(document.createElement(QStringLiteral("properties"))).toElement(); + QDomElement p = document.createElement(QStringLiteral("property")); + p.setAttribute(QStringLiteral("name"), name); + p.appendChild(document.createTextNode(value)); + properties.appendChild(p); + }; + auto sequence = [&document](QDomElement item, const QString &unit) { + item.removeChild(item.firstChildElement(QStringLiteral("sequentialNumbers"))); + QDomElement s = document.createElement(QStringLiteral("sequentialNumbers")); + QDomElement u = document.createElement(QStringLiteral("unit")); + u.appendChild(document.createTextNode(unit)); + s.appendChild(u); + item.appendChild(s); + }; + + property(document.documentElement(), QStringLiteral("site"), QStringLiteral("S")); + const QDomNodeList diagrams = document.elementsByTagName(QStringLiteral("diagram")); + QCOMPARE(diagrams.size(), 3); + for (int i = 0 ; i < diagrams.size() ; ++i) + { + QDomElement diagram = diagrams.at(i).toElement(); + diagram.setAttribute(QStringLiteral("folio"), QStringLiteral("F%id")); + diagram.setAttribute(QStringLiteral("plant"), QStringLiteral("P")); + diagram.setAttribute(QStringLiteral("locmach"), QStringLiteral("L")); + property(diagram, QStringLiteral("zone"), QStringLiteral("Z%1").arg(i)); + + QDomElement element = diagram.firstChildElement(QStringLiteral("elements")) + .firstChildElement(QStringLiteral("element")); + QVERIFY(!element.isNull()); + element.setAttribute(QStringLiteral("prefix"), QStringLiteral("X")); + sequence(element, QStringLiteral("7")); + QDomElement informations = element.firstChildElement(QStringLiteral("elementInformations")); + if (informations.isNull()) + informations = element.appendChild(document.createElement(QStringLiteral("elementInformations"))).toElement(); + while (!informations.firstChild().isNull()) + informations.removeChild(informations.firstChild()); + for (const auto &info : {std::make_pair(QStringLiteral("formula"), + QStringLiteral("K%total-%f-%F-%M-%LM-%c%l-%prefix-%{zone}-%{site}-%sequ_1")), + std::make_pair(QStringLiteral("label"), QStringLiteral("OLD"))}) { + QDomElement e = document.createElement(QStringLiteral("elementInformation")); + e.setAttribute(QStringLiteral("name"), info.first); + e.setAttribute(QStringLiteral("show"), QStringLiteral("1")); + e.appendChild(document.createTextNode(info.second)); + informations.appendChild(e); + } + + QDomElement conductor = diagram.firstChildElement(QStringLiteral("conductors")) + .firstChildElement(QStringLiteral("conductor")); + QVERIFY(!conductor.isNull()); + conductor.setAttribute(QStringLiteral("formula"), QStringLiteral("W%total-%id-%wf-%{zone}-%sequ_1")); + conductor.setAttribute(QStringLiteral("function"), QStringLiteral("N")); + conductor.setAttribute(QStringLiteral("num"), QStringLiteral("OLD")); + sequence(conductor, QStringLiteral("3")); } + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(document.toByteArray()); + file.close(); + + compareBothWays(saved); + // ...and they were worked out, not left as saved + const QString info = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("element_info")).toArray()) + .toJson(QJsonDocument::Compact)); + const QString wires = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("conductor")).toArray()) + .toJson(QJsonDocument::Compact)); + QVERIFY2(info.contains(QStringLiteral("K3-1-F1-P-L-")), qPrintable(info.left(400))); + QVERIFY2(info.contains(QStringLiteral("-X-Z0-S-7")), qPrintable(info.left(400))); + QVERIFY2(wires.contains(QStringLiteral("W3-3-N-Z2-3")), qPrintable(wires.left(400))); + QVERIFY(!info.contains(QStringLiteral("OLD"))); + QVERIFY(!wires.contains(QStringLiteral("OLD"))); + } + + // An element the folio does not build, and so the conductors ending on + // it, are left out the same way. + void unbuiltElementIsLeftOut() + { + const QString saved = m_dir.filePath(QStringLiteral("unbuilt.qet")); + QDomDocument document = resaved(saved); + QDomElement conductor = document.elementsByTagName(QStringLiteral("conductor")).at(0).toElement(); + QVERIFY(!conductor.isNull()); + const QString uuid = conductor.attribute(QStringLiteral("element1")); + const QDomNodeList elements = document.elementsByTagName(QStringLiteral("element")); + bool found = false; + for (int i = 0 ; i < elements.size() ; ++i) { + QDomElement e = elements.at(i).toElement(); + if (e.attribute(QStringLiteral("uuid")) == uuid) { + e.setAttribute(QStringLiteral("x"), QStringLiteral("nan")); + found = true; + } + } + QVERIFY(found); + QVERIFY(write(saved, document)); + compareBothWays(saved); + const QString wires = QString::fromUtf8(QJsonDocument(m_last.value(QStringLiteral("conductor")).toArray()) + .toJson(QJsonDocument::Compact)); + QVERIFY(!wires.contains(conductor.attribute(QStringLiteral("uuid")))); + } + + // Two elements on a folio numbering their terminals alike: which one the + // folio then refuses depends on the terminals' geometry, so the folios + // fill the database. + void clashingTerminalIdsFallBack() + { + const QString saved = m_dir.filePath(QStringLiteral("clash.qet")); + QDomDocument document = resaved(saved); + const QDomElement diagram = document.elementsByTagName(QStringLiteral("diagram")).at(0).toElement(); + QDomElement first = diagram.firstChildElement(QStringLiteral("elements")).firstChildElement(QStringLiteral("element")); + QDomElement second = first.nextSiblingElement(QStringLiteral("element")); + const QString id = first.firstChildElement(QStringLiteral("terminals")) + .firstChildElement(QStringLiteral("terminal")).attribute(QStringLiteral("id")); + QDomElement terminal = second.firstChildElement(QStringLiteral("terminals")) + .firstChildElement(QStringLiteral("terminal")); + QVERIFY(!id.isEmpty() && !terminal.isNull()); + terminal.setAttribute(QStringLiteral("id"), id); + QVERIFY(write(saved, document)); + QString how; + dump(saved, false, &how); + QCOMPARE(how, QStringLiteral("Project database filled from the folios: " + "two elements on a folio number their terminals alike")); } // A file whose items carry no saved uuid is filled from the folios, From df442c81e3c1cf30689965f24d0103c42f497978 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 21:59:53 +1300 Subject: [PATCH 36/45] Fix register_filetypes.bat for the portable ZIP and for non-admin users The Windows portable ZIP ships "Lancer QET_qt6.bat", but the script only looked for "Lancer QET.bat", so it stopped at its first check and never registered anything. It now tries the ZIP's name first and falls back to the installer's. It also wrote to HKEY_CLASSES_ROOT, which a standard (non-admin) Windows account cannot create keys in. It now writes to HKEY_CURRENT_USER\Software\Classes: per-user, no elevation needed, and Windows merges it into HKEY_CLASSES_ROOT for that user. qet_uninstall_file_associations.reg removes the HKCU keys first, then the old HKEY_CLASSES_ROOT ones as before. Tested under Wine 10 on the git10275 nightly ZIP: the old script aborts ("Lancer QET.bat ... n'a pas ete trouve"), the new one registers .qet, .elmt and .titleblock under HKCU with nothing under HKLM, the uninstall file removes them, and the "Lancer QET.bat" fallback works. Wine does not enforce admin rights, so the non-admin case itself is not proven there. Reported in #1148. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../files/qet_uninstall_file_associations.reg | 7 +++++ .../nsis_base/files/register_filetypes.bat | 31 ++++++++++--------- misc/qet_uninstall_file_associations.reg | 7 +++++ misc/register_filetypes.bat | 31 ++++++++++--------- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg b/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg index c5d0b0a7f..5de5bbc75 100644 --- a/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg +++ b/build-aux/windows/nsis_base/files/qet_uninstall_file_associations.reg @@ -1,5 +1,12 @@ Windows Registry Editor Version 5.00 +[-HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] +[-HKEY_CURRENT_USER\Software\Classes\.qet] +[-HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] +[-HKEY_CURRENT_USER\Software\Classes\.elmt] +[-HKEY_CURRENT_USER\Software\Classes\qet_element_file] +[-HKEY_CURRENT_USER\Software\Classes\.titleblock] +[-HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] [-HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] [-HKEY_CLASSES_ROOT\.qet] [-HKEY_CLASSES_ROOT\qet_diagram_file] diff --git a/build-aux/windows/nsis_base/files/register_filetypes.bat b/build-aux/windows/nsis_base/files/register_filetypes.bat index 29b429367..d0f2d69be 100644 --- a/build-aux/windows/nsis_base/files/register_filetypes.bat +++ b/build-aux/windows/nsis_base/files/register_filetypes.bat @@ -5,7 +5,9 @@ rem detecte le dossier courant et suppose que celui-ci contient bin\qelectrotech set current_dir=%~dp0 cd /d %current_dir% -set expected_qet_exe=%current_dir%Lancer QET.bat +rem l'archive portable fournit "Lancer QET_qt6.bat", l'installeur "Lancer QET.bat" +set expected_qet_exe=%current_dir%Lancer QET_qt6.bat +if not exist "%expected_qet_exe%" set expected_qet_exe=%current_dir%Lancer QET.bat set expected_project_ico=%current_dir%ico\application-x-qet-project.ico set expected_element_ico=%current_dir%ico\application-x-qet-element.ico set expected_titleblock_ico=%current_dir%ico\application-x-qet-titleblock.ico @@ -45,49 +47,50 @@ set final_element_ico=%expected_element_ico:\=\\% set final_titleblock_ico=%expected_titleblock_ico:\=\\% rem genere le fichier .reg pour enregistrer les associations de fichiers +rem HKEY_CURRENT_USER : ne demande pas les droits administrateur set reg_file=qet_install_file_associations.reg ( echo Windows Registry Editor Version 5.00 echo. rem Declaration de l'application - echo [HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.qet - echo [HKEY_CLASSES_ROOT\.qet] + echo [HKEY_CURRENT_USER\Software\Classes\.qet] echo @="qet_diagram_file" - echo [HKEY_CLASSES_ROOT\qet_diagram_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] echo @="QET diagram" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_diagram_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\DefaultIcon] echo @="%final_project_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_diagram_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.elmt - echo [HKEY_CLASSES_ROOT\.elmt] + echo [HKEY_CURRENT_USER\Software\Classes\.elmt] echo @="qet_element_file" - echo [HKEY_CLASSES_ROOT\qet_element_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file] echo @="QET element" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_element_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\DefaultIcon] echo @="%final_element_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_element_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.titleblock - echo [HKEY_CLASSES_ROOT\.titleblock] + echo [HKEY_CURRENT_USER\Software\Classes\.titleblock] echo @="qet_titleblock_file" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] echo @="QET title block template" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\DefaultIcon] echo @="%final_titleblock_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" ) > %reg_file% diff --git a/misc/qet_uninstall_file_associations.reg b/misc/qet_uninstall_file_associations.reg index c5d0b0a7f..5de5bbc75 100644 --- a/misc/qet_uninstall_file_associations.reg +++ b/misc/qet_uninstall_file_associations.reg @@ -1,5 +1,12 @@ Windows Registry Editor Version 5.00 +[-HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] +[-HKEY_CURRENT_USER\Software\Classes\.qet] +[-HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] +[-HKEY_CURRENT_USER\Software\Classes\.elmt] +[-HKEY_CURRENT_USER\Software\Classes\qet_element_file] +[-HKEY_CURRENT_USER\Software\Classes\.titleblock] +[-HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] [-HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] [-HKEY_CLASSES_ROOT\.qet] [-HKEY_CLASSES_ROOT\qet_diagram_file] diff --git a/misc/register_filetypes.bat b/misc/register_filetypes.bat index 29b429367..d0f2d69be 100755 --- a/misc/register_filetypes.bat +++ b/misc/register_filetypes.bat @@ -5,7 +5,9 @@ rem detecte le dossier courant et suppose que celui-ci contient bin\qelectrotech set current_dir=%~dp0 cd /d %current_dir% -set expected_qet_exe=%current_dir%Lancer QET.bat +rem l'archive portable fournit "Lancer QET_qt6.bat", l'installeur "Lancer QET.bat" +set expected_qet_exe=%current_dir%Lancer QET_qt6.bat +if not exist "%expected_qet_exe%" set expected_qet_exe=%current_dir%Lancer QET.bat set expected_project_ico=%current_dir%ico\application-x-qet-project.ico set expected_element_ico=%current_dir%ico\application-x-qet-element.ico set expected_titleblock_ico=%current_dir%ico\application-x-qet-titleblock.ico @@ -45,49 +47,50 @@ set final_element_ico=%expected_element_ico:\=\\% set final_titleblock_ico=%expected_titleblock_ico:\=\\% rem genere le fichier .reg pour enregistrer les associations de fichiers +rem HKEY_CURRENT_USER : ne demande pas les droits administrateur set reg_file=qet_install_file_associations.reg ( echo Windows Registry Editor Version 5.00 echo. rem Declaration de l'application - echo [HKEY_CLASSES_ROOT\Applications\qelectrotech.exe\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\Applications\qelectrotech.exe\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.qet - echo [HKEY_CLASSES_ROOT\.qet] + echo [HKEY_CURRENT_USER\Software\Classes\.qet] echo @="qet_diagram_file" - echo [HKEY_CLASSES_ROOT\qet_diagram_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file] echo @="QET diagram" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_diagram_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\DefaultIcon] echo @="%final_project_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_diagram_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_diagram_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.elmt - echo [HKEY_CLASSES_ROOT\.elmt] + echo [HKEY_CURRENT_USER\Software\Classes\.elmt] echo @="qet_element_file" - echo [HKEY_CLASSES_ROOT\qet_element_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file] echo @="QET element" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_element_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\DefaultIcon] echo @="%final_element_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_element_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_element_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" rem association de fichier *.titleblock - echo [HKEY_CLASSES_ROOT\.titleblock] + echo [HKEY_CURRENT_USER\Software\Classes\.titleblock] echo @="qet_titleblock_file" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file] echo @="QET title block template" echo "EditFlags"=dword:00000000 echo "BrowserFlags"=dword:00000008 - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\DefaultIcon] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\DefaultIcon] echo @="%final_titleblock_ico%,0" - echo [HKEY_CLASSES_ROOT\qet_titleblock_file\shell\open\command] + echo [HKEY_CURRENT_USER\Software\Classes\qet_titleblock_file\shell\open\command] echo @="\"%final_qet_exe%\" \"%%1\"" ) > %reg_file% From 1d816f432c6135d048cd2e3449f081b1458deafd Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 23:09:57 +1300 Subject: [PATCH 37/45] Fix the right-click menu offering Group again after grouping (#1144) Group and Ungroup are enabled in slot_updateComplexActions(), which ran only when the selection changed. Grouping, ungrouping and undoing either leave the selection as it is, and so does right-clicking an item that is already selected, so the actions kept the state from before. The right-click menu hides disabled actions, so it went on showing Group on a selection that was now one group (and Ungroup after ungrouping), and the one that would work was not in the menu at all. Diagram::setItemGroup() is the one place an item's group changes (group, ungroup, undo, redo, paste), so it now emits itemGroupChanged() and the editor refreshes its actions on it, beside the selectionChanged connection. Checked in the GUI on two free texts, saving after each step: right-click > Group, right-click again, Ctrl+Z, right-click again. Before, the second and third right-clicks offered Group again and did nothing (both saves still grouped). After, they offered Ungroup and ungrouped (0 grouped texts in both saves); Group, Ungroup, Group also round-trips. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/diagram.cpp | 1 + sources/diagram.h | 4 ++++ sources/qetdiagrameditor.cpp | 6 ++++++ 3 files changed, 11 insertions(+) diff --git a/sources/diagram.cpp b/sources/diagram.cpp index 3bb984338..f7c336ed6 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -2279,6 +2279,7 @@ void Diagram::setItemGroup(QGraphicsItem *item, const QUuid &group) if (m_project) { m_project->dataBase()->itemGroupChanged(item); } + emit itemGroupChanged(); } /** diff --git a/sources/diagram.h b/sources/diagram.h index 96299d036..ff7ff1f9b 100644 --- a/sources/diagram.h +++ b/sources/diagram.h @@ -325,6 +325,10 @@ class Diagram : public QGraphicsScene void diagramActivated(); void diagramInformationChanged(); + + /// Emitted by setItemGroup(): an item joined or left a group + /// without the selection changing (#1144) + void itemGroupChanged(); }; Q_DECLARE_METATYPE(Diagram *) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index e28927dcd..46cec2c5d 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -3185,6 +3185,12 @@ void QETDiagramEditor::diagramWasAdded(DiagramView *dv) this, &QETDiagramEditor::selectionChanged, Qt::DirectConnection); + //Grouping leaves the selection as it is, so without this Group + //and Ungroup would keep the state from before (#1144) + connect(dv->diagram(), + &Diagram::itemGroupChanged, + this, + &QETDiagramEditor::slot_updateComplexActions); connect(dv, &DiagramView::modeChanged, this, &QETDiagramEditor::slot_updateModeActions); connect(dv, &DiagramView::elementPlacementStarted, this, &QETDiagramEditor::rememberPlacedElement); } From 2dbaa6918622245ff8c82d297c8ada81e3dadade Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 23:23:32 +1300 Subject: [PATCH 38/45] Fix a group splitting apart when one of its items is locked (#1146) Grouping two symbols and then locking one of them (Lock position in its properties) left the group in a broken state: dragging the unlocked symbol pulled it away while the locked one stayed, and dragging the locked one did nothing. The move simply dropped locked items, so the rest of the group went without them. A group with a locked member now does not move at all, whichever member is dragged, and the status bar says why. This is the rule the item-groups proposal (discussion #1070) set out for this case. The same rule applies to the arrow keys and to the Align commands, which share DiagramContent::removeNonMovableItems(). Also fixed on the way, for a plain selection with a locked symbol: a wire between the locked symbol and one being dragged kept its user-placed text moving with the dragged end. Such a wire is now redrawn only, as a wire to an unselected symbol already is. Checked in the GUI on two symbols joined by a wire (grafcet example), master against this branch, positions read from the saved file: - drag the unlocked member: master moves it 190 px, this branch moves nothing and shows the message - arrow keys on the selected group (3 runs each): master moves the unlocked member, this branch nothing - the same two symbols ungrouped: both move the unlocked one, as before - user-placed wire text: master shifts it 190 px, this branch keeps it ctest: 34/34. Co-Authored-By: Claude Opus 5.5 --- sources/diagramcontent.cpp | 49 ++++++++++++++++++++- sources/diagramcontent.h | 3 ++ sources/elementsmover.cpp | 28 +++++++++++- sources/elementsmover.h | 2 + sources/qetgraphicsitem/diagramtextitem.cpp | 6 +++ sources/qetgraphicsitem/qetgraphicsitem.cpp | 5 +++ 6 files changed, 91 insertions(+), 2 deletions(-) diff --git a/sources/diagramcontent.cpp b/sources/diagramcontent.cpp index 99e58e476..fc54e83c9 100644 --- a/sources/diagramcontent.cpp +++ b/sources/diagramcontent.cpp @@ -18,6 +18,7 @@ #include "diagramcontent.h" #include "diagram.h" +#include "itemgroups.h" #include "qetgraphicsitem/ViewItem/qetgraphicstableitem.h" #include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/conductortextitem.h" @@ -261,7 +262,7 @@ void DiagramContent::clear() */ int DiagramContent::removeNonMovableItems() { - int count_ = 0; + int count_ = removePinnedGroups(); const QList elements_set = m_elements; for(Element *elmt : elements_set) { @@ -287,9 +288,55 @@ int DiagramContent::removeNonMovableItems() } } + //A wire whose two ends no longer both move is redrawn, not moved, + //or its text would be carried off with the end that still moves + const QList conductors_to_move = m_conductors_to_move; + for (Conductor *conductor : conductors_to_move) { + if (!m_elements.contains(conductor->terminal1->parentElement()) || + !m_elements.contains(conductor->terminal2->parentElement())) { + m_conductors_to_move.removeAll(conductor); + if (!m_conductors_to_update.contains(conductor)) + m_conductors_to_update << conductor; + } + } + return count_; } +/** + @brief DiagramContent::removePinnedGroups + A group (#1070) with a locked member does not move at all: moving the + rest would pull the group apart around the member that stays (#1146). + Only a locked member that is in this content pins its group. + Called first by removeNonMovableItems(), while the locked items are + still here to be found. + @return the number of removed items +*/ +int DiagramContent::removePinnedGroups() +{ + QSet pinned; + for (Element *elmt : std::as_const(m_elements)) + if (!elmt->isMovable()) + pinned << ItemGroups::groupOf(elmt); + for (DiagramImageItem *img : std::as_const(m_images)) + if (!img->isMovable()) + pinned << ItemGroups::groupOf(img); + for (QetShapeItem *shape : std::as_const(m_shapes)) + if (!shape->isMovable()) + pinned << ItemGroups::groupOf(shape); + pinned.remove(QUuid()); + if (pinned.isEmpty()) + return 0; + + auto isPinned = [&pinned](const QGraphicsItem *item) { + return pinned.contains(ItemGroups::groupOf(item)); + }; + return int(m_elements.removeIf(isPinned) + + m_images.removeIf(isPinned) + + m_shapes.removeIf(isPinned) + + m_text_fields.removeIf(isPinned)); +} + DiagramContent &DiagramContent::operator+=(const DiagramContent &other) { for(Element *elmt : other.m_elements) diff --git a/sources/diagramcontent.h b/sources/diagramcontent.h index 85f3dc7f2..92d7c1caf 100644 --- a/sources/diagramcontent.h +++ b/sources/diagramcontent.h @@ -100,6 +100,9 @@ class DiagramContent DiagramContent& operator+=(const DiagramContent& other); bool potentialIsManaged(QListconductors); bool hasTextEditing(); + + private: + int removePinnedGroups(); }; QDebug &operator<<(QDebug, DiagramContent &); #endif diff --git a/sources/elementsmover.cpp b/sources/elementsmover.cpp index 28c8c344d..d8127cae9 100644 --- a/sources/elementsmover.cpp +++ b/sources/elementsmover.cpp @@ -20,6 +20,7 @@ #include "autobreakconductor.h" #include "conductorautonumerotation.h" #include "diagram.h" +#include "itemgroups.h" #include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/conductortextitem.h" #include "qetgraphicsitem/diagramimageitem.h" @@ -61,6 +62,8 @@ bool ElementsMover::isReady() const */ int ElementsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_item) { + m_driver_held = false; + // They must be no movement in progress if (m_movement_running) return(-1); @@ -86,6 +89,17 @@ int ElementsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_item) m_moved_content = DiagramContent(diagram); m_moved_content.removeNonMovableItems(); + //A grouped driver left out of the move belongs to a group that one + //locked member holds in place: it stays with the group + m_driver_held = driver_item + && !ItemGroups::groupOf(driver_item).isNull() + && !m_moved_content.items().contains(driver_item); + if (m_driver_held && m_status_bar) { + m_status_bar->showMessage(QObject::tr( + "Ce groupe ne peut pas être déplacé : " + "la position d'un de ses éléments est verrouillée.")); + } + //Remove element text and text group, if the parent element is selected. const auto element_text{m_moved_content.m_element_texts}; for(const auto &deti : element_text) { @@ -109,6 +123,17 @@ int ElementsMover::beginMovement(Diagram *diagram, QGraphicsItem *driver_item) return(m_moved_content.count()); } +/** + @brief ElementsMover::holds + @return true if @a item is the item the user drags and it must not move: + it is in a group that a locked member keeps in place (#1146). Each item + that drives a movement asks before moving itself. +*/ +bool ElementsMover::holds(const QGraphicsItem *item) const +{ + return m_driver_held && item && item == m_movement_driver; +} + /** @brief ElementsMover::continueMovement Add a move to the current movement. @@ -255,7 +280,8 @@ void ElementsMover::endMovement() m_movement_running = false; m_moved_content.clear(); - if (m_status_bar) { + //Keep saying why a held group did not move + if (m_status_bar && !m_driver_held) { m_status_bar->clearMessage(); } } diff --git a/sources/elementsmover.h b/sources/elementsmover.h index a8fd1857d..2074a88eb 100644 --- a/sources/elementsmover.h +++ b/sources/elementsmover.h @@ -52,6 +52,7 @@ class ElementsMover { int beginMovement(Diagram *, QGraphicsItem * = nullptr); void continueMovement(const QPointF &); void endMovement(); + bool holds(const QGraphicsItem *item) const; // attributes private: @@ -59,6 +60,7 @@ class ElementsMover { QPointF m_current_movement; Diagram *m_diagram{nullptr}; QGraphicsItem *m_movement_driver{nullptr}; + bool m_driver_held{false}; DiagramContent m_moved_content; QPointer m_status_bar; diff --git a/sources/qetgraphicsitem/diagramtextitem.cpp b/sources/qetgraphicsitem/diagramtextitem.cpp index 771a3d42d..6e1a7aa02 100644 --- a/sources/qetgraphicsitem/diagramtextitem.cpp +++ b/sources/qetgraphicsitem/diagramtextitem.cpp @@ -366,6 +366,12 @@ void DiagramTextItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if(diagram_ && m_first_move) diagram_->elementsMover().beginMovement(diagram_, this); + if (diagram_ && diagram_->elementsMover().holds(this)) { + m_first_move = false; + event->accept(); + return; + } + QPointF old_pos = pos(); //Set the actual pos diff --git a/sources/qetgraphicsitem/qetgraphicsitem.cpp b/sources/qetgraphicsitem/qetgraphicsitem.cpp index 505c4cc37..631ee8d85 100644 --- a/sources/qetgraphicsitem/qetgraphicsitem.cpp +++ b/sources/qetgraphicsitem/qetgraphicsitem.cpp @@ -144,6 +144,11 @@ void QetGraphicsItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) //It's the first movement, we signal it to parent diagram diagram()->elementsMover().beginMovement(diagram(), this); } + if (diagram() && diagram()->elementsMover().holds(this)) { + m_first_move = false; + event->accept(); + return; + } //we apply the mouse movement QPointF old_pos = pos(); From 795484ff4f54e0ca1e033294f44308380ced145e Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 23:24:38 +1300 Subject: [PATCH 39/45] Let Align and Snap to grid move drawn shapes (#1145) Rectangles, ellipses, lines and polygons were left out of the Align submenu: with only shapes selected every command was greyed out, and a shape outside a group was ignored when aligning it with a symbol. Shapes now take part like pictures. Their edges are the shape as drawn (the new QetShapeItem::sceneOutlineRect(), without the pen, the 6 px selection margin or the wider hover outline; the old code used sceneBoundingRect() for grouped shapes and so aligned them 6 px off). The point that goes on the grid is the top-left corner of that box: a rectangle's corner, an ellipse's box. pos() is not used, because a shape drawn with Ctrl held or rotated has its corners off the grid while pos() is on it. The menu's enable rule now counts what the command counts, a group as one. Before, two symbols in one group enabled the six align commands, which then did nothing and only said so in the status bar. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/qetdiagrameditor.cpp | 9 +- sources/qetgraphicsitem/qetshapeitem.cpp | 11 ++ sources/qetgraphicsitem/qetshapeitem.h | 1 + sources/undocommand/alignselectioncommand.cpp | 187 ++++++++++-------- sources/undocommand/alignselectioncommand.h | 5 +- 5 files changed, 127 insertions(+), 86 deletions(-) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index e28927dcd..a7a540795 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -2207,7 +2207,7 @@ void QETDiagramEditor::alignGroupTriggered(QAction *action) if (mode == AlignSelectionCommand::SnapToGrid) message = tr("La sélection est déjà sur la grille"); else if (command->itemCount() < 2) - message = tr("Sélectionnez au moins deux éléments, images, textes ou groupes non verrouillés"); + message = tr("Sélectionnez au moins deux éléments, images, textes, formes ou groupes non verrouillés"); else message = tr("La sélection est déjà alignée, à la grille près"); delete command; @@ -2487,11 +2487,10 @@ void QETDiagramEditor::slot_updateComplexActions() | DiagramContent::Images); m_depth_action_group->setEnabled(list.isEmpty()? false : true); - //Align actions: symbols, pictures and free texts take part. + //Align actions: symbols, pictures, free texts and shapes take part, + //counted the way the command counts them, a group as one. //Snapping needs one of them, lining them up needs two. - const int alignable = selected_elements_count - + selected_image - + dc.count(DiagramContent::TextFields); + const int alignable = AlignSelectionCommand::unitCount(dc); m_align_actions_group.setEnabled(!ro && alignable); const QList align_actions = m_align_actions_group.actions(); for (QAction *action : align_actions.mid(1)) diff --git a/sources/qetgraphicsitem/qetshapeitem.cpp b/sources/qetgraphicsitem/qetshapeitem.cpp index 81295c571..e1d610603 100644 --- a/sources/qetgraphicsitem/qetshapeitem.cpp +++ b/sources/qetgraphicsitem/qetshapeitem.cpp @@ -602,6 +602,17 @@ QPainterPath QetShapeItem::outline() const return path; } +/** + @brief QetShapeItem::sceneOutlineRect + @return the box around the shape as drawn, in scene coordinates: + without the pen width, the selection margin of boundingRect() or the + wider outline shape() gives a hovered shape. +*/ +QRectF QetShapeItem::sceneOutlineRect() const +{ + return mapToScene(outline()).boundingRect(); +} + /** @brief QetShapeItem::shape @return the shape of this item diff --git a/sources/qetgraphicsitem/qetshapeitem.h b/sources/qetgraphicsitem/qetshapeitem.h index fe7408e71..cf1c823fd 100644 --- a/sources/qetgraphicsitem/qetshapeitem.h +++ b/sources/qetgraphicsitem/qetshapeitem.h @@ -219,6 +219,7 @@ class QetShapeItem : public QetGraphicsItem QRectF boundingRect() const override; QPainterPath shape() const override; + QRectF sceneOutlineRect() const; protected: void paint( diff --git a/sources/undocommand/alignselectioncommand.cpp b/sources/undocommand/alignselectioncommand.cpp index f3a63c4b7..f05267066 100644 --- a/sources/undocommand/alignselectioncommand.cpp +++ b/sources/undocommand/alignselectioncommand.cpp @@ -30,6 +30,78 @@ #include #include +namespace { + /** + One selected item that takes part: where its edges and centre + are, and the point that goes on the grid, on the grid its own + drag uses (divided by @a divisor, as TextGrid::snap() does). + */ + struct Entry { + QGraphicsObject *item; + Alignment::Item geometry; + QPointF snap_point; + qreal divisor; + }; + + /** + @return the items of @a dc that take part, symbols first so a + group with a symbol in it snaps on that symbol. + */ + QList entriesOf(const DiagramContent &dc, qreal text_divisor) + { + QList entries; + //A symbol's edges are its own outline, without its texts, and + //its centre is its origin point: that is where its wires leave. + for (Element *element : std::as_const(dc.m_elements)) + entries << Entry{element, {element->sceneBoundingRect(), element->pos()}, + element->pos(), 1}; + //A picture's edges are the picture's, without its caption + for (DiagramImageItem *image : std::as_const(dc.m_images)) + { + const QRectF rect = image->mapRectToScene(image->imageRect()); + entries << Entry{image, {rect, rect.center()}, image->pos(), 1}; + } + for (IndependentTextItem *text : std::as_const(dc.m_text_fields)) + entries << Entry{text, {text->sceneBoundingRect(), text->sceneBoundingRect().center()}, + text->pos(), text_divisor}; + //A shape's edges are the shape as drawn. What goes on the grid is + //the top-left corner of that box: a rectangle's corner, an + //ellipse's box, a line's end. Its pos() says nothing a user can + //see, and a rotated shape's is never on the grid anyway. + for (QetShapeItem *shape : std::as_const(dc.m_shapes)) + { + const QRectF rect = shape->sceneOutlineRect(); + entries << Entry{shape, {rect, rect.center()}, rect.topLeft(), 1}; + } + return entries; + } + + /** + @return @a entries sorted into the pieces that line up: a group + (#1070) is one piece, every other item a piece of its own. Each + piece is the list of its indexes into @a entries. + */ + QList> unitsOf(const QList &entries) + { + QList> units; + QHash group_units; + for (int i = 0 ; i < entries.size() ; ++i) + { + const QUuid group = ItemGroups::groupOf(entries.at(i).item); + if (group.isNull()) { + units << QList{i}; + continue; + } + if (!group_units.contains(group)) { + group_units.insert(group, units.size()); + units << QList(); + } + units[group_units.value(group)] << i; + } + return units; + } +} + /** @brief AlignSelectionCommand::AlignSelectionCommand Works out the movement of every selected item for @a mode. Nothing @@ -53,28 +125,9 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC const int y_grid = settings.value(QStringLiteral("diagrameditor/Ygrid"), Diagram::yGrid).toInt(); const qreal text_divisor = settings.value(TextGrid::settings_key, 1).toReal(); - //Each kind goes where dragging it would have left it: symbols and - //pictures on the folio grid, free texts on the text grid. - //Shapes are left out: they are made of several points and no single - //one of them is the obvious one to snap. - struct Entry { - QGraphicsObject *item; - Alignment::Item geometry; - qreal divisor; - }; - QList entries; - //A symbol's edges are its own outline, without its texts, and its - //centre is its origin point: that is where its wires leave. - for (Element *element : std::as_const(dc.m_elements)) - entries << Entry{element, {element->sceneBoundingRect(), element->pos()}, 1}; - //A picture's edges are the picture's, without its caption - for (DiagramImageItem *image : std::as_const(dc.m_images)) - { - const QRectF rect = image->mapRectToScene(image->imageRect()); - entries << Entry{image, {rect, rect.center()}, 1}; - } - for (IndependentTextItem *text : std::as_const(dc.m_text_fields)) - entries << Entry{text, {text->sceneBoundingRect(), text->sceneBoundingRect().center()}, text_divisor}; + //Each kind goes where dragging it would have left it: symbols, + //pictures and shapes on the folio grid, free texts on the text grid. + const QList entries = entriesOf(dc, text_divisor); m_item_count = entries.size(); auto move = [this](QGraphicsObject *item, const QPointF &offset) @@ -86,60 +139,15 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC if (mode == SnapToGrid) { for (const Entry &entry : std::as_const(entries)) - move(entry.item, Alignment::gridOffset(entry.item->pos(), x_grid, y_grid, entry.divisor)); + move(entry.item, Alignment::gridOffset(entry.snap_point, x_grid, y_grid, entry.divisor)); setText(QObject::tr("Aligner %n objet(s) sur la grille", "", childCount())); return; } //A group (#1070) lines up as one piece: its edges are its members' //together, its centre the middle of that box, and all its members - //move by the same amount, so the group keeps its shape. Shapes in a - //group come along; shapes outside one are left out, as above. - struct Unit { - QList members; - QList parts; - QGraphicsObject *snap_item = nullptr; ///< lands on its grid - qreal divisor = 1; - }; - QList units; - QHash group_units; - //Returns a reference into units, which a later call can grow: - //use it before calling again, never keep it - auto unitFor = [&](QGraphicsObject *item) -> Unit & - { - const QUuid group = ItemGroups::groupOf(item); - if (group.isNull()) { - units << Unit(); - return units.last(); - } - if (!group_units.contains(group)) { - group_units.insert(group, units.size()); - units << Unit(); - } - return units[group_units.value(group)]; - }; - for (const Entry &entry : std::as_const(entries)) - { - Unit &unit = unitFor(entry.item); - unit.members << entry.item; - unit.parts << entry.geometry; - //Symbols come first in entries, so a group with one snaps on it - if (!unit.snap_item) { - unit.snap_item = entry.item; - unit.divisor = entry.divisor; - } - } - for (QetShapeItem *shape : std::as_const(dc.m_shapes)) - { - if (ItemGroups::groupOf(shape).isNull()) - continue; - Unit &unit = unitFor(shape); - unit.members << shape; - const QRectF rect = shape->sceneBoundingRect(); - unit.parts << Alignment::Item{rect, rect.center()}; - if (!unit.snap_item) - unit.snap_item = shape; - } + //move by the same amount, so the group keeps its shape. + const QList> units = unitsOf(entries); m_item_count = units.size(); //Lining up a single item on itself would only snap it @@ -158,21 +166,39 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC } QList geometry; - for (const Unit &unit : std::as_const(units)) - geometry << Alignment::combined(unit.parts); + for (const QList &unit : units) + { + QList parts; + for (int i : unit) + parts << entries.at(i).geometry; + geometry << Alignment::combined(parts); + } const QList offsets = Alignment::alignOffsets(geometry, edge); - for (int i = 0 ; i < units.size() ; ++i) + for (int u = 0 ; u < units.size() ; ++u) { - const Unit &unit = units.at(i); - const QPointF offset = Alignment::snappedOffset(unit.snap_item->pos(), offsets.at(i), edge, - x_grid, y_grid, unit.divisor); - for (QGraphicsObject *member : unit.members) - move(member, offset); + //The piece lands on the grid of its first member + const Entry &first = entries.at(units.at(u).first()); + const QPointF offset = Alignment::snappedOffset(first.snap_point, offsets.at(u), edge, + x_grid, y_grid, first.divisor); + for (int i : units.at(u)) + move(entries.at(i).item, offset); } setText(QObject::tr("Aligner %n objet(s)", "", childCount())); } +/** + @brief AlignSelectionCommand::unitCount + @return the number of pieces the selection in @a dc lines up as: + every symbol, picture, free text and shape, a group counting as one. + Locked items are counted too, so the command can say why they did + not move. +*/ +int AlignSelectionCommand::unitCount(const DiagramContent &dc) +{ + return unitsOf(entriesOf(dc, 1)).size(); +} + /** @brief AlignSelectionCommand::undo */ @@ -220,8 +246,9 @@ int AlignSelectionCommand::lockedCount() const } /** - @return the number of things that took part: symbols, pictures and - free texts whose position is not locked, a group counting as one. + @return the number of things that took part: symbols, pictures, free + texts and shapes whose position is not locked, a group counting as + one. */ int AlignSelectionCommand::itemCount() const { diff --git a/sources/undocommand/alignselectioncommand.h b/sources/undocommand/alignselectioncommand.h index de782ee4e..88ec1a392 100644 --- a/sources/undocommand/alignselectioncommand.h +++ b/sources/undocommand/alignselectioncommand.h @@ -22,11 +22,12 @@ #include class Diagram; +class DiagramContent; /** @brief The AlignSelectionCommand class Moves each selected item by its own amount, as one undo step. - Symbols, pictures and free texts take part; locked items are left + Symbols, pictures, free texts and shapes take part; locked items are left where they are and counted, so the caller can say so. */ class AlignSelectionCommand : public QUndoCommand @@ -52,6 +53,8 @@ class AlignSelectionCommand : public QUndoCommand int lockedCount() const; int itemCount() const; + static int unitCount(const DiagramContent &dc); + private: QPointer m_diagram; int m_locked_count = 0; From 1fdb41400662848fb9688ac56c342ca9138985a0 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 23:37:33 +1300 Subject: [PATCH 40/45] Give the Align commands icons The seven Edit > Align commands (Snap to grid and the six align commands, discussion #1069) had no icons: the menu, the context menu, command search and the shortcut bar showed them as bare text. They are pixel-grid SVGs in ico/scalable/ drawn like the folio and Add PDF icons: 24 pixel canvas, art on the inner 22 pixel grid, one currentColor ink so misc/make_icon_themes.py writes the dark copies. Each align icon is a guide line with two boxes of different lengths against it; the centre ones show the guide only between the boxes. Snap to grid is a faint grid with one box sitting exactly on a cell. The boxes have a solid outline and a 35 % tinted inside, which keeps the guide the strongest mark at 16 pixels. Names follow the freedesktop icon names (align-horizontal-left, ...). Co-Authored-By: Claude Opus 5.5 (1M context) --- ico/icon-themes.qrc | 14 ++++++++++++++ ico/scalable/align-horizontal-center.svg | 13 +++++++++++++ ico/scalable/align-horizontal-left.svg | 13 +++++++++++++ ico/scalable/align-horizontal-right.svg | 13 +++++++++++++ ico/scalable/align-vertical-bottom.svg | 13 +++++++++++++ ico/scalable/align-vertical-center.svg | 13 +++++++++++++ ico/scalable/align-vertical-top.svg | 13 +++++++++++++ ico/scalable/snap-to-grid.svg | 13 +++++++++++++ .../scalable/align-horizontal-center.svg | 13 +++++++++++++ .../qet-dark/scalable/align-horizontal-left.svg | 13 +++++++++++++ .../scalable/align-horizontal-right.svg | 13 +++++++++++++ .../qet-dark/scalable/align-vertical-bottom.svg | 13 +++++++++++++ .../qet-dark/scalable/align-vertical-center.svg | 13 +++++++++++++ .../qet-dark/scalable/align-vertical-top.svg | 13 +++++++++++++ ico/themes/qet-dark/scalable/snap-to-grid.svg | 13 +++++++++++++ misc/make_icon_themes.py | 7 +++++++ sources/qetdiagrameditor.cpp | 17 +++++++++-------- sources/qeticons.cpp | 14 ++++++++++++++ sources/qeticons.h | 7 +++++++ 19 files changed, 233 insertions(+), 8 deletions(-) create mode 100644 ico/scalable/align-horizontal-center.svg create mode 100644 ico/scalable/align-horizontal-left.svg create mode 100644 ico/scalable/align-horizontal-right.svg create mode 100644 ico/scalable/align-vertical-bottom.svg create mode 100644 ico/scalable/align-vertical-center.svg create mode 100644 ico/scalable/align-vertical-top.svg create mode 100644 ico/scalable/snap-to-grid.svg create mode 100644 ico/themes/qet-dark/scalable/align-horizontal-center.svg create mode 100644 ico/themes/qet-dark/scalable/align-horizontal-left.svg create mode 100644 ico/themes/qet-dark/scalable/align-horizontal-right.svg create mode 100644 ico/themes/qet-dark/scalable/align-vertical-bottom.svg create mode 100644 ico/themes/qet-dark/scalable/align-vertical-center.svg create mode 100644 ico/themes/qet-dark/scalable/align-vertical-top.svg create mode 100644 ico/themes/qet-dark/scalable/snap-to-grid.svg diff --git a/ico/icon-themes.qrc b/ico/icon-themes.qrc index 377a7d4b0..cce9d1fda 100644 --- a/ico/icon-themes.qrc +++ b/ico/icon-themes.qrc @@ -267,12 +267,19 @@ 128x128/project.png 128x128/settings.png 128x128/terminalstrip.png + scalable/align-horizontal-center.svg + scalable/align-horizontal-left.svg + scalable/align-horizontal-right.svg + scalable/align-vertical-bottom.svg + scalable/align-vertical-center.svg + scalable/align-vertical-top.svg scalable/diagram.svg scalable/folio-delete.svg scalable/folio-new.svg scalable/folio-properties.svg scalable/label.svg scalable/pdf-import.svg + scalable/snap-to-grid.svg breeze-icons/scalable/apps/hidef/edit-opacity.svg breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg @@ -457,12 +464,19 @@ themes/qet-dark/48x48/view-pim-journal.png themes/qet-dark/128x128/plasmagik.png themes/qet-dark/128x128/settings.png + themes/qet-dark/scalable/align-horizontal-center.svg + themes/qet-dark/scalable/align-horizontal-left.svg + themes/qet-dark/scalable/align-horizontal-right.svg + themes/qet-dark/scalable/align-vertical-bottom.svg + themes/qet-dark/scalable/align-vertical-center.svg + themes/qet-dark/scalable/align-vertical-top.svg themes/qet-dark/scalable/diagram.svg themes/qet-dark/scalable/folio-delete.svg themes/qet-dark/scalable/folio-new.svg themes/qet-dark/scalable/folio-properties.svg themes/qet-dark/scalable/label.svg themes/qet-dark/scalable/pdf-import.svg + themes/qet-dark/scalable/snap-to-grid.svg themes/qet-dark/scalable/edit-opacity.svg themes/qet-dark/scalable/image-flip-horizontal.svg themes/qet-dark/scalable/image-flip-vertical.svg diff --git a/ico/scalable/align-horizontal-center.svg b/ico/scalable/align-horizontal-center.svg new file mode 100644 index 000000000..f4dc74ef1 --- /dev/null +++ b/ico/scalable/align-horizontal-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-horizontal-left.svg b/ico/scalable/align-horizontal-left.svg new file mode 100644 index 000000000..80af690e0 --- /dev/null +++ b/ico/scalable/align-horizontal-left.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-horizontal-right.svg b/ico/scalable/align-horizontal-right.svg new file mode 100644 index 000000000..d2c483ebb --- /dev/null +++ b/ico/scalable/align-horizontal-right.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-vertical-bottom.svg b/ico/scalable/align-vertical-bottom.svg new file mode 100644 index 000000000..b5c251ffe --- /dev/null +++ b/ico/scalable/align-vertical-bottom.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-vertical-center.svg b/ico/scalable/align-vertical-center.svg new file mode 100644 index 000000000..adece279e --- /dev/null +++ b/ico/scalable/align-vertical-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/align-vertical-top.svg b/ico/scalable/align-vertical-top.svg new file mode 100644 index 000000000..dfeb4cd04 --- /dev/null +++ b/ico/scalable/align-vertical-top.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/scalable/snap-to-grid.svg b/ico/scalable/snap-to-grid.svg new file mode 100644 index 000000000..7262c14c2 --- /dev/null +++ b/ico/scalable/snap-to-grid.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-horizontal-center.svg b/ico/themes/qet-dark/scalable/align-horizontal-center.svg new file mode 100644 index 000000000..53131882f --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-horizontal-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-horizontal-left.svg b/ico/themes/qet-dark/scalable/align-horizontal-left.svg new file mode 100644 index 000000000..11cddde30 --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-horizontal-left.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-horizontal-right.svg b/ico/themes/qet-dark/scalable/align-horizontal-right.svg new file mode 100644 index 000000000..752c2716b --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-horizontal-right.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-vertical-bottom.svg b/ico/themes/qet-dark/scalable/align-vertical-bottom.svg new file mode 100644 index 000000000..57486d607 --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-vertical-bottom.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-vertical-center.svg b/ico/themes/qet-dark/scalable/align-vertical-center.svg new file mode 100644 index 000000000..67f02d699 --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-vertical-center.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/align-vertical-top.svg b/ico/themes/qet-dark/scalable/align-vertical-top.svg new file mode 100644 index 000000000..39af4483b --- /dev/null +++ b/ico/themes/qet-dark/scalable/align-vertical-top.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/ico/themes/qet-dark/scalable/snap-to-grid.svg b/ico/themes/qet-dark/scalable/snap-to-grid.svg new file mode 100644 index 000000000..6b05eda1b --- /dev/null +++ b/ico/themes/qet-dark/scalable/snap-to-grid.svg @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/misc/make_icon_themes.py b/misc/make_icon_themes.py index 455f6a9e6..5e381c555 100644 --- a/misc/make_icon_themes.py +++ b/misc/make_icon_themes.py @@ -90,12 +90,19 @@ ALIASES = { # SVG icons referenced from sources/qeticons.cpp. ico/scalable/ holds the # ones drawn for QET as vectors; one file serves every size. SVGS = [ + "scalable/align-horizontal-center.svg", + "scalable/align-horizontal-left.svg", + "scalable/align-horizontal-right.svg", + "scalable/align-vertical-bottom.svg", + "scalable/align-vertical-center.svg", + "scalable/align-vertical-top.svg", "scalable/diagram.svg", "scalable/folio-delete.svg", "scalable/folio-new.svg", "scalable/folio-properties.svg", "scalable/label.svg", "scalable/pdf-import.svg", + "scalable/snap-to-grid.svg", "breeze-icons/scalable/apps/hidef/edit-opacity.svg", "breeze-icons/scalable/apps/hidef/image-flip-horizontal-symbolic.svg", "breeze-icons/scalable/apps/hidef/image-flip-vertical-symbolic.svg", diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index e28927dcd..02e7e5668 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -938,26 +938,27 @@ void QETDiagramEditor::setUpActions() //Align actions. No default shortcut: they are reached from the //Edit menu, the selection's context menu and the command search, //and a user can bind one if they want. - QAction *snap_to_grid = m_align_actions_group.addAction(tr("Aligner sur la grille")); + QAction *snap_to_grid = m_align_actions_group.addAction(QET::Icons::SnapToGrid, tr("Aligner sur la grille")); ShortcutManager::instance().registerAction(snap_to_grid, "diagrameditor.snap_selection_to_grid", tr("Éditeur de schémas"), QKeySequence()); snap_to_grid->setStatusTip(tr("Remet les éléments, images et textes sélectionnés sur la grille", "status bar tip")); snap_to_grid->setData("snap_selection_to_grid"); const struct { const char *id; + const QIcon &icon; QString text; QString tip; } align_actions[] = { - {"align_left", tr("Aligner à gauche"), tr("Aligne les bords gauches des objets sélectionnés", "status bar tip")}, - {"align_hcenter", tr("Centrer horizontalement"), tr("Aligne les objets sélectionnés sur une même verticale, par leur point d'origine pour les éléments", "status bar tip")}, - {"align_right", tr("Aligner à droite"), tr("Aligne les bords droits des objets sélectionnés", "status bar tip")}, - {"align_top", tr("Aligner en haut"), tr("Aligne les bords supérieurs des objets sélectionnés", "status bar tip")}, - {"align_vcenter", tr("Centrer verticalement"), tr("Aligne les objets sélectionnés sur une même horizontale, par leur point d'origine pour les éléments", "status bar tip")}, - {"align_bottom", tr("Aligner en bas"), tr("Aligne les bords inférieurs des objets sélectionnés", "status bar tip")} + {"align_left", QET::Icons::AlignLeft, tr("Aligner à gauche"), tr("Aligne les bords gauches des objets sélectionnés", "status bar tip")}, + {"align_hcenter", QET::Icons::AlignHCenter, tr("Centrer horizontalement"), tr("Aligne les objets sélectionnés sur une même verticale, par leur point d'origine pour les éléments", "status bar tip")}, + {"align_right", QET::Icons::AlignRight, tr("Aligner à droite"), tr("Aligne les bords droits des objets sélectionnés", "status bar tip")}, + {"align_top", QET::Icons::AlignTop, tr("Aligner en haut"), tr("Aligne les bords supérieurs des objets sélectionnés", "status bar tip")}, + {"align_vcenter", QET::Icons::AlignVCenter, tr("Centrer verticalement"), tr("Aligne les objets sélectionnés sur une même horizontale, par leur point d'origine pour les éléments", "status bar tip")}, + {"align_bottom", QET::Icons::AlignBottom, tr("Aligner en bas"), tr("Aligne les bords inférieurs des objets sélectionnés", "status bar tip")} }; for (const auto &a : align_actions) { - QAction *action = m_align_actions_group.addAction(a.text); + QAction *action = m_align_actions_group.addAction(a.icon, a.text); ShortcutManager::instance().registerAction(action, QStringLiteral("diagrameditor.") + QLatin1String(a.id), tr("Éditeur de schémas"), QKeySequence()); action->setStatusTip(a.tip); action->setData(QString::fromLatin1(a.id)); diff --git a/sources/qeticons.cpp b/sources/qeticons.cpp index a605c74a7..aa8b86c79 100644 --- a/sources/qeticons.cpp +++ b/sources/qeticons.cpp @@ -23,6 +23,12 @@ namespace QET { namespace Icons { QIcon Add; QIcon AddFolio; + QIcon AlignBottom; + QIcon AlignHCenter; + QIcon AlignLeft; + QIcon AlignRight; + QIcon AlignTop; + QIcon AlignVCenter; QIcon Allowed; QIcon ApplicationExit; QIcon ArrowLeft; @@ -169,6 +175,7 @@ namespace QET { QIcon SendBackward; QIcon Settings; QIcon SinglePage; + QIcon SnapToGrid; QIcon South; QIcon Start; QIcon TableOfContent; @@ -395,6 +402,12 @@ void QET::Icons::initIcons() Add = QIcon::fromTheme("list-add"); AddFolio = QIcon::fromTheme("folio-new"); + AlignBottom = QIcon::fromTheme("align-vertical-bottom"); + AlignHCenter = QIcon::fromTheme("align-horizontal-center"); + AlignLeft = QIcon::fromTheme("align-horizontal-left"); + AlignRight = QIcon::fromTheme("align-horizontal-right"); + AlignTop = QIcon::fromTheme("align-vertical-top"); + AlignVCenter = QIcon::fromTheme("align-vertical-center"); Allowed = QIcon::fromTheme("user-online"); ApplicationExit = QIcon::fromTheme("application-exit"); ArrowLeft = QIcon::fromTheme("arrow-left"); @@ -581,6 +594,7 @@ void QET::Icons::initIcons() SendBackward = QIcon::fromTheme("send_backward"); Settings = QIcon::fromTheme("settings"); SinglePage = QIcon::fromTheme("single_page"); + SnapToGrid = QIcon::fromTheme("snap-to-grid"); South = QIcon::fromTheme("south"); Start = QIcon::fromTheme("start"); TableOfContent = QIcon::fromTheme("table-of-content"); diff --git a/sources/qeticons.h b/sources/qeticons.h index af21e8e54..31a4d0aa8 100644 --- a/sources/qeticons.h +++ b/sources/qeticons.h @@ -30,6 +30,12 @@ namespace QET { // namespace extern QIcon Add; extern QIcon AddFolio; + extern QIcon AlignBottom; + extern QIcon AlignHCenter; + extern QIcon AlignLeft; + extern QIcon AlignRight; + extern QIcon AlignTop; + extern QIcon AlignVCenter; extern QIcon Allowed; extern QIcon ApplicationExit; extern QIcon ArrowLeft; @@ -177,6 +183,7 @@ namespace QET { extern QIcon SendBackward; extern QIcon Settings; extern QIcon SinglePage; + extern QIcon SnapToGrid; extern QIcon South; extern QIcon Start; extern QIcon TableOfContent; From cc0521bfe54cbcaab832dfb06b225370feb7b127 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Tue, 29 Sep 2026 23:39:23 +1300 Subject: [PATCH 41/45] Drag a shape onto the grid by its corner, not its hidden origin (#1145) A dragged shape put its pos() on the grid. pos() cannot be seen, and it is off the drawn corner whenever the shape was drawn or resized with Ctrl held, or rotated, so such a shape stayed off the grid however it was dragged. Worse, Snap to grid (previous commit) moves pos() off the grid to put the corner on it, so the next drag undid the snap. QetShapeItem now overrides setPos(), which only the drag calls through the virtual: dragged alone or with other shapes only, the top-left corner of the drawn outline goes on the grid. Dragged together with anything else it snaps by pos() as before, because the rest of the selection follows this shape's movement and a corner correction would take the symbols off the grid. Ctrl still drags freely: the snap goes through Diagram::snapToGrid(), which reads it. Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/qetgraphicsitem/qetshapeitem.cpp | 29 ++++++++++++++++++++++++ sources/qetgraphicsitem/qetshapeitem.h | 3 +++ 2 files changed, 32 insertions(+) diff --git a/sources/qetgraphicsitem/qetshapeitem.cpp b/sources/qetgraphicsitem/qetshapeitem.cpp index e1d610603..a6d0202a9 100644 --- a/sources/qetgraphicsitem/qetshapeitem.cpp +++ b/sources/qetgraphicsitem/qetshapeitem.cpp @@ -33,6 +33,7 @@ #include "../undocommand/promoteshapecommand.h" #include +#include #include #include #include @@ -613,6 +614,34 @@ QRectF QetShapeItem::sceneOutlineRect() const return mapToScene(outline()).boundingRect(); } +/** + @brief QetShapeItem::setPos + Called with the unsnapped position while the shape is dragged. Dragged + on its own, or with other shapes only, the shape goes on the grid by the + top-left corner of what is drawn, as Snap to grid does: its pos() cannot + be seen, and it is off the corner whenever the shape was drawn or + resized with Ctrl held, or rotated. Dragged together with anything + else, it snaps by pos() as before: the whole selection follows this + shape's movement, and correcting the corner would take the symbols off + the grid instead. + @param p the new position of the item +*/ +void QetShapeItem::setPos(const QPointF &p) +{ + const auto selection = scene() ? scene()->selectedItems() : QList(); + const bool shapes_only = std::all_of(selection.cbegin(), selection.cend(), + [](const QGraphicsItem *item) { return item->type() == QetShapeItem::Type; }); + if (!shapes_only || !isMovable()) { + QetGraphicsItem::setPos(p); + return; + } + + const QPointF corner = sceneOutlineRect().topLeft() + (p - pos()); + const QPointF snapped = p + Diagram::snapToGrid(corner) - corner; + if (snapped != pos()) + QGraphicsItem::setPos(snapped); +} + /** @brief QetShapeItem::shape @return the shape of this item diff --git a/sources/qetgraphicsitem/qetshapeitem.h b/sources/qetgraphicsitem/qetshapeitem.h index cf1c823fd..610f06bf5 100644 --- a/sources/qetgraphicsitem/qetshapeitem.h +++ b/sources/qetgraphicsitem/qetshapeitem.h @@ -221,6 +221,9 @@ class QetShapeItem : public QetGraphicsItem QPainterPath shape() const override; QRectF sceneOutlineRect() const; + using QetGraphicsItem::setPos; + void setPos(const QPointF &p) override; + protected: void paint( QPainter *painter, From 10d7dae5d0bcab6486d9f1c03c0a1ed0b014657f Mon Sep 17 00:00:00 2001 From: Kellermorph Date: Tue, 29 Sep 2026 14:14:02 +0200 Subject: [PATCH 42/45] German Translation --- lang/qet_de.ts | 606 +++++++++++++++++++++++++------------------------ 1 file changed, 308 insertions(+), 298 deletions(-) diff --git a/lang/qet_de.ts b/lang/qet_de.ts index 3cc47bc69..c266e62dd 100644 --- a/lang/qet_de.ts +++ b/lang/qet_de.ts @@ -667,7 +667,7 @@ Renumber element(s)… - + Element(e) neu nummerieren… @@ -742,7 +742,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Aperçu - Vorschau + Vorschau @@ -791,13 +791,14 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Requête refusée - + Antrag abgelehnt Erreur dans la requête : %1 - + Fehler in der Abfrage: +%1 @@ -883,12 +884,12 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Rechercher une commande… - + Eine Befehl suchen… Entrée pour lancer · Échap pour fermer - + Enter zum Starten · Esc zum Schließen @@ -930,82 +931,82 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Noir - Schwarz + Schwarz Marron - Braun + Braun Gris - Grau + Grau Bleu - Blau + Blau Vert - Grün + Grün Rouge - Rot + Rot Orange - Orange + Orange Violet - Violett + Violett Blanc - Weiß + Weiß Couleur de conducteur - + Leiterfarbe Applique une couleur aux conducteurs sélectionnés, et l'utilise pour les prochains conducteurs tracés status bar tip - + Wendet eine Farbe auf die ausgewählten Leiter an und verwendet diese für die nächsten gezeichneten Leiter Récemment utilisées - + Zuletzt verwendet Autre couleur… - + Eine andere Farbe… Modifier la couleur de %n conducteur(s) undo caption - - - + + Farbe von %n Leiter ändern + Farbe von %n Leitern ändern Choisir une couleur de conducteur - + Eine Leiterfarbe auswählen @@ -1038,24 +1039,24 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Appliquer les propriétés à l'ensemble des conducteurs de ce potentiel - Eigenschaften auf alle Verbindungen dieses Potenzials übertragen + Eigenschaften auf alle Verbindungen dieses Potenzials übertragen Modifier les propriétés d'un conducteur undo caption - Leitereigenschaften ändern + Leitereigenschaften ändern Modifier les propriétés de plusieurs conducteurs undo caption - Eigenschaften von mehreren Leitern ändern + Eigenschaften von mehreren Leitern ändern Conducteur - + Leiter @@ -1510,63 +1511,63 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Blanc - Weiß + Weiß Blanc cassé - + Cremefarben Gris clair - Hellgrau + Hellgrau Gris - Grau + Grau Gris foncé - + Dunkelgrau Noir - Schwarz + Schwarz Couleur de fond du folio - + Hintergrundfarbe der Seite Choisir la couleur de fond du folio status bar tip - + Hintergrundfarbe des Seite auswählen Couleur système - + Systemfarbe Récemment utilisées - + Zuletzt verwendet Autre couleur… - + Eine andere Farbe… Choisir une couleur de fond - + Eine Hintergrundfarbe auswählen @@ -1643,7 +1644,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Cliquez pour poser le collage, Échap ou clic droit pour annuler status bar tip while positioning a paste - + Klicken Sie, um einzufügen, ESC oder rechte Maustaste zum Abbrechen. @@ -1969,7 +1970,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Renvoi de folio - + Seitenverweis @@ -2044,42 +2045,42 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Dupliquer - + Duplizieren pas de grille - + kein Raster Espacement : - + Abstand: Haut - Oben + Oben Bas - Unten + Unten Gauche - Linksbündig + Linksbündig Droite - Rechtsbündig + Rechtsbündig Direction : - + Ausrichtung: @@ -2097,7 +2098,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Redimensionner un texte d'élément - + Größe eines Elementtextes ändern @@ -2562,12 +2563,12 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. ex. 80.5 - + Beispiel 80.5 Nombre décimal avec un point comme séparateur (ex. 80.5) - + Dezimalzahl mit einem Punkt als Trennzeichen (z. B. 80.5) @@ -2779,12 +2780,12 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El ex. 80.5 - + Beispiel 80.5 Nombre décimal avec un point comme séparateur (ex. 80.5) - + Dezimalzahl mit einem Punkt als Trennzeichen (z. B. 80.5) @@ -2826,80 +2827,80 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Personnaliser la barre… - + Die Leiste anpassen… Glissez les commandes et les éléments dans la barre, hors de la barre, ou d'une place à l'autre. Un double-clic fait passer une commande ou un élément d'une liste à l'autre. - + Ziehen Sie Befehle und Elemente in die Leiste, aus der Leiste heraus oder von einer Position zur anderen. Durch einen Doppelklick wird ein Befehl oder ein Element von einer Liste in eine andere verschoben. Rechercher un élément… - + Ein Element suchen… Éléments : - + Elemente: Valeurs par défaut - + Standardwerte Annuler - + Abbrechen Terminé - + Fertig Dans la barre : - + In der Leiste: Autres commandes : - + Weitere Kommandos: Entrée pour insérer · Échap pour fermer - + Eingabe zum Einfügen · Esc zum Schließen Glisser pour changer la largeur de la barre - + Ziehen Sie den Rand, um die Breite der Leiste zu ändern Personnaliser la barre de raccourcis : %1 - + Die Schnellzugriffsleiste anpassen: %1 Aucun résultat - + Keine Ergebnisse Tapez pour rechercher un élément · Échap pour fermer - + Tippen Sie hier, um nach einem Element zu suchen · Esc zum Schließen Palette vide — glissez des éléments dans votre collection personnelle, ou tapez pour rechercher - + Leere Palette – Ziehe Elemente in deine persönliche Sammlung oder tippe, um zu suchen @@ -3595,22 +3596,22 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Importer des rapports depuis un fichier - + Berichte aus einer Datei importieren Importer... - + Importieren... Exporter tous les rapports enregistrés vers un fichier - + Alle gespeicherten Berichte in eine Datei exportieren Exporter... - + Exportieren... @@ -3646,24 +3647,24 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Exporter - Exportieren + Exportieren Aucun rapport enregistré à exporter. - + Es sind keine Berichte zum Exportieren gespeichert. Exporter les rapports - + Berichte exportieren Fichiers JSON (*.json) - + JSON-Dateien (*.json) @@ -3671,58 +3672,58 @@ Der angezeigte Name des Elements lässt sich separat in den Eigenschaften des El Erreur - Fehler + Fehler Impossible d'écrire dans %1. - + %1 kann nicht beschrieben werden. Importer des rapports - + Berichte importieren Impossible de lire %1. - + %1 kann nicht gelesen werden. %1 ne contient pas des rapports QElectroTech valides. - + %1 enthält keine gültigen QElectroTech-Berichte. Importer - Import + Import Ce fichier ne contient aucun rapport. - + Diese Datei enthält keine Berichte. Rapport déjà existant - + Bereits vorhandener Bericht Un rapport nommé « %1 » existe déjà. Le remplacer ? - + Ein Bericht mit dem Namen „%1“ existiert bereits. Möchten Sie ihn ersetzen? Impossible d'écrire la configuration locale. - + Die lokale Konfiguration kann nicht geschrieben werden. %1 rapport(s) importé(s), %2 ignoré(s). - + %1 Bericht(e) importiert, %2 ignoriert. @@ -4757,12 +4758,12 @@ Texte und Zahlen sind ebenso erlaubt. Appliquez ou annulez les modifications en cours avant de déplacer - + Übernehmen oder verwerfen Sie die aktuellen Änderungen, bevor Sie den Ordner verschieben Déplacer les bornes sélectionnées vers le bornier choisi - + Die ausgewählten Klemmen an die ausgewählte Klemmenleiste verschieben @@ -4962,12 +4963,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Couleur de l'application - + Farbe der Anwendung Afficher les propriétés d'un conducteur sélectionné dans le panneau Propriétés de la sélection - + Die Eigenschaften eines ausgewählten Leiters im Fenster „Eigenschaften der Auswahl“ anzeigen @@ -4977,32 +4978,32 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Par défaut, un double-clic insère l'élément sur le folio ; l'édition reste accessible par le menu contextuel. - + Standardmäßig wird das Element durch einen Doppelklick in das Folio eingefügt; die Bearbeitung ist weiterhin über das Kontextmenü möglich. Double-cliquer dans la collection ouvre l'éditeur d'élément au lieu de l'insérer - + Ein Doppelklick in der Sammlung öffnet den Element-Editor, anstatt das Element einzufügen Après un clic qui sélectionne un élément ou un conducteur, les commandes de la barre de raccourcis apparaissent près du curseur et s'effacent quand la souris s'éloigne. - + Nach einem Klick, mit dem ein Element oder ein Leiter ausgewählt wird, erscheinen die Befehle der Schnellzugriffsleiste in der Nähe des Cursors und verschwinden wieder, sobald die Maus wegbewegt wird. Afficher les commandes près de la sélection - + Befehle neben der Auswahl anzeigen Maintenir le bouton droit et glisser dans une direction lance une commande de la barre de raccourcis. Un simple clic droit ouvre toujours le menu contextuel, au relâchement du bouton. - + Wenn Sie die rechte Maustaste gedrückt halten und in eine Richtung ziehen, wird ein Befehl aus der Schnellzugriffsleiste ausgeführt. Ein einfacher Rechtsklick öffnet wie gewohnt das Kontextmenü, sobald Sie die Taste loslassen. Gestes de la souris avec le bouton droit - + Mausaktionen mit der rechten Maustaste @@ -5027,12 +5028,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Autoriser l'exécution de scripts JavaScript (Projet > Exécuter un script, et --run) - + Ausführung von JavaScript-Skripten zulassen (Projekt > Skript ausführen und --run) Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. Désactivé par défaut ; n'exécutez que des scripts dont vous connaissez l'origine. - + Ein Skript wird mit Ihren Berechtigungen ausgeführt: Es kann das geöffnete Projekt lesen und ändern sowie Dateien schreiben. Standardmäßig deaktiviert; führen Sie nur Skripte aus, deren Herkunft Sie kennen. @@ -5106,12 +5107,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Grille des textes déplacés à la souris - + Raster der mit der Maus verschobenen Texte Fraction de la grille des folios. Maintenir Ctrl pendant le déplacement pour placer librement. - + Rasterteil der Seiten. Halten Sie beim Verschieben die Strg-Taste gedrückt, um die Elemente frei zu platzieren. @@ -5218,12 +5219,12 @@ Hier unten kann der Standardwert dieses Textfeldes für Ihre eigenen Bauteile de Désactivée - + Deaktiviert Activé par la variable d'environnement QET_ENABLE_SCRIPTING ; ce réglage est sans effet tant qu'elle est définie. - + Wird durch die Umgebungsvariable QET_ENABLE_SCRIPTING aktiviert; diese Einstellung hat keine Auswirkung, solange sie gesetzt ist. @@ -5746,12 +5747,12 @@ Alle Einstellungen außer „Keine Rundung“ können je nach folgenden Faktoren Libellé - + Wortlaut Texte affiché sous l'image. Il suit l'image quand elle est déplacée, copiée ou tournée. - + Unter dem Bild angezeigter Text. Er folgt dem Bild, wenn dieses verschoben, kopiert oder gedreht wird. @@ -5782,7 +5783,7 @@ Alle Einstellungen außer „Keine Rundung“ können je nach folgenden Faktoren Modifier le libellé d'une image - + Die Bildbeschriftung ändern @@ -6052,22 +6053,22 @@ Veuillez utiliser l'éditeur avancé pour cela. Nom, label ou information de l'élément, ou case (ex. B13 ou 3-B13)… - + Name, Bezeichnung oder Angabe des Elements oder Feldes (z. B. B13 oder 3-B13)… Case %1 - + Feld %1 Folio %1, case %2 - + Seite %1, Feld %2 Folio %1 (%2), case %3 - + Seite %1 (%2), Feld %3 Nom, label ou information de l'élément… @@ -6179,7 +6180,7 @@ Veuillez utiliser l'éditeur avancé pour cela. Masquer les éléments maîtres pleins - + Gefüllte Master-Elemente ausblenden Remarque : les éléments maîtres ayant atteint leur nombre maximal d'esclaves sont masqués. @@ -6604,27 +6605,27 @@ Möchten Sie diesen Slave-Kontakt trotzdem verbinden? Définir les règles de numérotation automatique par défaut pour les nouveaux projets : - + Standardregeln für die automatische Nummerierung neuer Projekte festlegen: Conducteurs - Leiter + Leiter Eléments - Bauteile + Bauteile Folios - Seiten + Seiten Numérotation auto - Auto-Nummerierung + Auto-Nummerierung @@ -7050,7 +7051,7 @@ Möchten Sie diesen Slave-Kontakt trotzdem verbinden? Masquer les éléments connectés - + Verbundene Elemente ausblenden @@ -7323,12 +7324,12 @@ Folgende Variablen sind inkompatibel: Importer depuis un autre projet... - + Aus einem anderen Projekt importieren... Reprendre les numérotations automatiques enregistrées dans un autre projet - + Die in einem anderen Projekt gespeicherten automatischen Nummerierungen übernehmen @@ -7344,75 +7345,75 @@ Folgende Variablen sind inkompatibel: Importer les numérotations d'un projet - + Nummerierungen aus einem Projekt importieren Projet QElectroTech (*.qet) - QElectroTech-Projekt (*.qet) + QElectroTech-Projekt (*.qet) Import impossible - + Import nicht möglich Impossible d'ouvrir %1 - + %1 kann nicht geöffnet werden %1 n'est pas un projet QElectroTech valide. - + %1 ist kein gültiges QElectroTech-Projekt. Aucune numérotation - + Keine Nummerierungvvvvvv Ce projet ne contient aucune numérotation automatique. - + Dieses Projekt enthält keine automatische Nummerierung. Numérotations à importer - + Zu importierende Nummerierungen Numérotations trouvées dans %1 : - + In %1 gefundene Nummerierungen: %1 : %2 (existe déjà) - + %1: %2 (existiert bereits) Remplacer les numérotations de même nom - + Nummerierungen mit demselben Namen ersetzen Import terminé - + Import abgeschlossen %1 numérotation(s) importée(s), %2 conservée(s) telles quelles. - + %1 Nummerierung(en) importiert, %2 unverändert beibehalten. %1 numérotation(s) importée(s). - + %1 importierte Nummerierung(en). @@ -8741,24 +8742,24 @@ Verfügbare Optionen: Dupli&quer - + Duplizieren Copie la sélection, décalée de l'espacement configuré status bar tip - + Kopiert die Auswahl, versetzt um den festgelegten Abstand Configurer la duplication... - + Duplizierung einrichten... Choisir l'espacement et la direction utilisés par Dupliquer status bar tip - + Abstand und Richtung für „Duplizieren“ auswählen @@ -8775,18 +8776,19 @@ Verfügbare Optionen: Grille des textes - + Textübersicht Grille d'accrochage des textes déplacés à la souris. Maintenir Ctrl pendant le déplacement pour placer librement. - + Raster zum Ausrichten von Texten, die mit der Maus verschoben werden. +Halten Sie während des Verschiebens die Strg-Taste gedrückt, um die Texte frei zu platzieren. Désactivée - + Deaktiviert @@ -8801,22 +8803,22 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Garder les en-têtes visibles - + Kopfzeilen sichtbar lassen Garde les numéros de colonne et les lettres de ligne du folio visibles au bord de la vue - + Lass die Spaltennummern und Zeilenbuchstaben des Folios am Rand der Ansicht sichtbar bleiben Afficher les limites des cases - + Rahmen anzeigen Trace les limites des colonnes et des lignes du folio sur le schéma, à l'écran seulement - + Zeichne die Begrenzungslinien der Spalten und Zeilen des Folios auf der Zeichnung ein – allerdings nur auf dem Bildschirm @@ -8853,22 +8855,22 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Recharger les dessins des éléments - + Zeichnungen der Elemente neu laden Redessine chaque élément placé d'après sa définition actuelle, sans avoir à fermer et rouvrir le projet (action non annulable) - + Jedes platzierte Element wird entsprechend seiner aktuellen Definition neu gezeichnet, ohne dass das Projekt geschlossen und erneut geöffnet werden muss (Aktion kann nicht rückgängig gemacht werden) Exécuter un script... - + Ein Skript ausführen... Exécute un script JavaScript sur le projet courant (voir qet.* dans le script pour l'API disponible) - + Führt ein JavaScript-Skript für das aktuelle Projekt aus (siehe „qet.*“ im Skript für die verfügbare API) @@ -8901,57 +8903,57 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Insérer le dernier élément - + Das letzte Element einfügen Place à nouveau le dernier élément inséré status bar tip - + Füge das zuletzt eingefügte Element erneut ein Rechercher une commande… - + Einen Befehl suchen… Tapez une partie du nom d'une commande et appuyez sur Entrée pour la lancer status bar tip - + Geben Sie einen Teil des Befehlsnamens ein und drücken Sie die Eingabetaste, um den Befehl auszuführen Insérer un élément… - + Ein Element einfügen… Ouvre le sélecteur d'éléments à la position du curseur status bar tip - + Öffnet die Elementauswahl an der Cursorposition Barre de raccourcis - + Schnellzugriffsleiste Ouvre à la position du curseur les commandes utiles pour la sélection, et le sélecteur d'éléments status bar tip - + Öffnet an der Cursorposition die für die Auswahl nützlichen Befehle sowie die Elementauswahl Répéter la dernière commande - + Den letzten Befehl wiederholen Relance le dernier outil de dessin ou la dernière insertion d'élément (Entrée sur le folio) status bar tip - + Das zuletzt verwendete Zeichenwerkzeug oder die zuletzt eingefügte Komponente erneut aufrufen (Eingabe auf dem Folio) @@ -8962,35 +8964,35 @@ Maintenir Ctrl pendant le déplacement pour placer librement. Aligner sur la grille - + Am Raster ausrichten Remet les éléments, images et textes sélectionnés sur la grille status bar tip - + Setzt die ausgewählten Elemente, Bilder und Texte wieder auf das Raster Sélectionner tous les conducteurs - + Alle Leiter auswählen Sélectionner tous les champs de texte - + Alle Textfelder auswählen Sélectionne tous les conducteurs du folio, désélectionne le reste status bar tip - + Wählt alle Leiter des Seite aus, hebt die Auswahl der übrigen auf Sélectionne tous les champs de texte du folio, désélectionne le reste status bar tip - + Wählt alle Textfelder des Seite aus, hebt die Auswahl der übrigen Felder auf @@ -9107,17 +9109,17 @@ Maintenir Ctrl pendant le déplacement pour placer librement. A&jouter - + Hinzufügen Aligner - + Ausrichten Lignes et colonnes - + Zeilen und Spalten @@ -9198,21 +9200,21 @@ Entfernen Sie die Überbrückung und/oder löschen Sie die Ebenen der betroffene %n objet(s) remis sur la grille - - + + %n Objekt(e) auf das Raster gesetzt La sélection est déjà sur la grille - + Die Auswahl steht bereits in der Startaufstellung (%n objet(s) verrouillé(s) laissé(s) en place) - - + + (%n gesperrte Objekte wurden an Ort und Stelle belassen) @@ -9230,52 +9232,52 @@ Entfernen Sie die Überbrückung und/oder löschen Sie die Ebenen der betroffene Répéter : insérer « %1 » - + Wiederholen: „%1“ einfügen Répéter : %1 - + Wiederholen: %1 folio %1 - + Seite %1 %n élément(s) redessiné(s). - - + + %n Element(e) wurden neu gestaltet. %n élément(s) dont la définition est introuvable ou illisible : leur dessin actuel a été conservé. - - + + %n Element(e), deren Definition nicht gefunden werden konnte oder unlesbar ist: Ihre aktuelle Darstellung wurde beibehalten. %n élément(s) non redessiné(s) : leur taille, leur point de saisie ou leurs bornes ont changé (borne ajoutée, supprimée ou déplacée). - - + + %n nicht neu gezeichnetes Element: Seine Größe, sein Griffpunkt oder seine Begrenzungen haben sich geändert (Begrenzung hinzugefügt, gelöscht oder verschoben). Pour les mettre à jour, il faut les supprimer puis les réinsérer. Attention : cette opération supprime les conducteurs déjà reliés à ces éléments, qu'il faudra retracer. - + Um sie zu aktualisieren, müssen sie gelöscht und anschließend erneut eingefügt werden. Achtung: Bei diesem Vorgang werden die bereits mit diesen Elementen verbundenen Leiter gelöscht, die dann neu verbunden werden müssen. Exécuter un script - + v @@ -9284,18 +9286,22 @@ Entfernen Sie die Überbrückung und/oder löschen Sie die Ebenen der betroffene Un script s'exécute avec vos droits : il peut lire et modifier le projet ouvert et écrire des fichiers. N'exécutez que des scripts dont vous connaissez l'origine. Activer les scripts ? Ce réglage est modifiable dans Configurer QElectroTech > Général > Projets. - + Skripte sind deaktiviert. + +Ein Skript wird mit Ihren Berechtigungen ausgeführt: Es kann das geöffnete Projekt lesen und ändern sowie Dateien schreiben. Führen Sie nur Skripte aus, deren Herkunft Sie kennen. + +Skripte aktivieren? Diese Einstellung kann unter „QElectroTech konfigurieren“ > „Allgemein“ > „Projekte“ geändert werden. Scripts JavaScript (*.js);;Tous les fichiers (*) - + JavaScript-Skripte (*.js);;Alle Dateien (*) Textes %1 - + Texte %1 @@ -10369,12 +10375,12 @@ Die erforderlichen Bedingungen wurden nicht erfüllt Renumber elements - + Elemente neu nummerieren Renumber elements (%1) - + Elemente neu nummerieren (%1) @@ -10456,17 +10462,17 @@ Was möchten Sie tun? Impossible de vérifier la requête : la base de données ne peut pas être mise en lecture seule. - + Die Abfrage kann nicht überprüft werden: Die Datenbank kann nicht in den schreibgeschützten Modus versetzt werden. Seules les requêtes en lecture seule sont autorisées : cette requête modifierait la base de données. - + Es sind nur Lesezugriffe zulässig: Dieser Zugriff würde die Datenbank verändern. Requête SQL invalide : %1 - + Ungültige SQL-Abfrage: %1 @@ -11125,7 +11131,7 @@ Was möchten Sie tun? %n élément(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Bauteil %n Bauteile @@ -11134,7 +11140,7 @@ Was möchten Sie tun? %n conducteur(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Leiter %n Leiter @@ -11143,7 +11149,7 @@ Was möchten Sie tun? %n champ(s) de texte Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Textfeld %n Textfelder @@ -11152,16 +11158,16 @@ Was möchten Sie tun? %n image(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - - - + + %n Bild + %n Bilder %n forme(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Form %n Formen @@ -11170,7 +11176,7 @@ Was möchten Sie tun? %n texte(s) d'élément Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Bauteiltext %n Bauteiltexte @@ -11179,7 +11185,7 @@ Was möchten Sie tun? %n tableau(s) Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - + %n Tabelle %n Tabellen @@ -11188,9 +11194,9 @@ Was möchten Sie tun? %n plan(s) de bornes Sentence fragment used in an automatically generated list of different objects, e.g. objects moved at the same time, which will be combined into a sentence. - - - + + %n Klemmenplan + %n Klemmenpläne @@ -11610,7 +11616,7 @@ Was möchten Sie tun? Macros - + Makros Makros @@ -11712,16 +11718,16 @@ Der QET-Name dieses Ordners konnte nicht gelesen werden; stattdessen wird der Or %n texte(s) - - - + + %n Text + %n Texte Pivoter %1 - + Drehen %1 @@ -11946,17 +11952,17 @@ Möchten Sie sie ersetzen? Largeur [mm] - + Breite [mm] Hauteur [mm] - + Höhe [mm] Profondeur [mm] - + Tiefe [mm] @@ -12679,17 +12685,17 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta Grille des textes %1. Relâcher Maj et maintenir Ctrl pour placer librement. - + Textraster %1. Lassen Sie die Umschalttaste los und halten Sie die Strg-Taste gedrückt, um die Texte frei zu platzieren. Grille des textes désactivée. - + Textgitter deaktiviert. %n texte(s) d'élément - + %n Bauteiltext %n Bauteiltexte @@ -12698,9 +12704,9 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta %n groupe(s) de textes - - - + + %n Textgruppe + %n Textgruppen @@ -12708,222 +12714,222 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta Déplacer %1 - + %1 verschieben Ajouter une borne à un groupe de bornes - + Eine Klemme zu einer Klemmengruppe hinzufügen Ajouter la borne %1 à un groupe de bornes - + Klemme %1 zu einer Klemmengruppe hinzufügen Ajouter une borne au groupe de bornes %1 - + Eine Klemme zur Klemmengruppe %1 hinzufügen Ajouter la borne %1 au groupe de bornes %2 - + Klemme %1 zur Klemmengruppe %2 hinzufügen Ajouter %n borne(s) à un groupe de bornes - - - + + %n Klemme zu einer Klemmengruppe hinzufügen + %n Klemmen zu einer Klemmengruppe hinzufügen Ajouter %n borne(s) au groupe de bornes %1 - - - + + %n Klemme zur Klemmengruppe %1 hinzufügen + %n Klemmen zur Klemmengruppe %1 hinzufügen Enlever %n borne(s) d'un groupe de bornes - - - + + %n Klemme aus einer Klemmengruppe entfernen + %n Klemmen aus einer Klemmengruppe entfernen Enlever %n borne(s) du groupe de bornes %1 - - - + + %n Klemme aus der Klemmengruppe %1 entfernen + %n Klemmen aus der Klemmengruppe %1 entfernen Déplacer une borne d'un groupe de bornes vers un autre groupe de bornes - + Eine Klemme von einer Klemmenleiste in eine andere Klemmenleiste verschieben Déplacer une borne d'un groupe de bornes vers le groupe de bornes %1 - + Einen Klemmenpunkt aus einer Klemmleiste in die Klemmenleiste %1 verschieben Déplacer une borne du groupe de bornes %1 vers un autre groupe de bornes - + Einen Klemmenpunkt aus der Klemmenleiste %1 in eine andere Klemmenleiste verschieben Déplacer une borne du groupe de bornes %1 vers le groupe de bornes %2 - + Eine Klemme aus der Klemmenleiste %1 in die Klemmenleiste %2 verschieben Déplacer la borne %1 d'un groupe de bornes vers un autre groupe de bornes - + Klemme %1 von einer Klemmenleiste in eine andere Klemmenleiste verschieben Déplacer la borne %1 d'un groupe de bornes vers le groupe de bornes %2 - + Klemme %1 von einer Klemmenleiste in die Klemmenleiste %2 verschieben Déplacer la borne %1 du groupe de bornes %2 vers un autre groupe de bornes - + Klemme %1 der Klemmenleiste %2 in eine andere Klemmenleiste verschieben Déplacer la borne %1 du groupe de bornes %2 vers le groupe de bornes %3 - + Klemme %1 von der Klemmenleiste %2 in die Klemmenleiste %3 verschieben Déplacer %n borne(s) d'un groupe de bornes vers un autre groupe de bornes - - - + + %n Klemme von einer Klemmenleiste in eine andere Klemmenleiste verschieben + %n Klemmen von einer Klemmenleiste in eine andere Klemmenleiste verschieben Déplacer %n borne(s) d'un groupe de bornes vers le groupe de bornes %1 - - - + + %n Klemme aus einer Klemmenleiste in die Klemmenleiste %1 verschieben + %n Klemmen aus einer Klemmenleiste in die Klemmenleiste %1 verschieben Déplacer %n borne(s) du groupe de bornes %1 vers un autre groupe de bornes - - - + + %n Klemme aus der Klemmenleiste %1 in eine andere Klemmenleiste verschieben + %n Klemmen aus der Klemmenleiste %1 in eine andere Klemmenleiste verschieben Déplacer %n borne(s) du groupe de bornes %1 vers le groupe de bornes %2 - - - + + %n Klemme aus der Klemmenleiste %1 in die Klemmenleiste %2 verschieben + %n Klemmen aus der Klemmenleiste %1 in die Klemmenleiste %2 verschieben Aligner %n objet(s) sur la grille - - - + + %n Objekt am Raster ausrichten + %n Objekte am Raster ausrichten Ajouter %1 - Einfügen von .%1 + Einfügen von .%1 Modifier les propriétés du conducteur - + Eigenschaften des Leiters bearbeiten Modifier un texte - + Einen Text bearbeiten Modifier la couleur d'un texte - Farbe eines Texts ändern + Farbe eines Texts ändern Pivoter un texte - + Einen Text drehen Modifier le trait d'une forme - Linie einer Form ändern + Linie einer Form ändern Modifier le remplissage d'une forme - Füllung einer Form ändern + Füllung einer Form ändern Pivoter une forme - + Eine Form drehen Modifier la forme d'%1 - Form von %1 ändern + Form von %1 ändern Fermer/Ouvrir %1 - + %1 schließen/öffnen Redimensionner une image - Die Größe eines Bildes ändern + Die Größe eines Bildes ändern Pivoter une image - + Ein Bild drehen Modifier un texte d'élément - Text eines Bauteils bearbeiten + Text eines Bauteils bearbeiten Numéroter automatiquement un élément - Ein Element automatisch nummerieren + Ein Element automatisch nummerieren Rechercher et remplacer - + Suchen und Ersetzen Script - + Skript @@ -12932,7 +12938,11 @@ Bitte laden Sie diese über den Link herunter und entpacken Sie sie in den Insta Un script a accès à l'ensemble du projet et peut écrire des fichiers, aussi cette fonction est-elle désactivée par défaut. Pour l'activer : Configurer QElectroTech > Général > Projets, ou définir la variable d'environnement QET_ENABLE_SCRIPTING=1 pour une exécution sans interface (CI, traitement par lot). - + Skripte sind deaktiviert. + +Ein Skript hat Zugriff auf das gesamte Projekt und kann Dateien schreiben, daher ist diese Funktion standardmäßig deaktiviert. + +So aktivieren Sie sie: QElectroTech konfigurieren > Allgemein > Projekte, oder setzen Sie die Umgebungsvariable QET_ENABLE_SCRIPTING=1 für eine Ausführung ohne Benutzeroberfläche (CI, Stapelverarbeitung). @@ -13409,27 +13419,27 @@ Bitte fügen Sie eine neue Tabelle hinzu oder passen Sie die vorhandene Tabelle Renumber element(s) - + Element(e) neu nummerieren Scope - + Umfang All schemes - + Alle Schemen One scheme - + Ein Schema Scheme: - + Schema: @@ -14517,53 +14527,53 @@ Andere Felder werden nicht verwendet. La barre de raccourcis s'ouvre à la position du curseur (touche S par défaut). Elle montre les commandes choisies ici selon ce qui est sélectionné, puis le sélecteur d'éléments. - + Die Schnellzugriffsleiste öffnet sich an der Cursorposition (standardmäßig Taste „S“). Sie zeigt zunächst die hier ausgewählten Befehle entsprechend der aktuellen Auswahl an und anschließend die Elementauswahl. Ajouter → - + Hinzufügen → ← Retirer - + ← Entfernen Monter - + Nach oben Descendre - + Nach unten Valeurs par défaut - + Standardwerte Commandes disponibles - + Verfügbare Befehle Dans la barre, dans l'ordre - + In der Leiste, in der Reihenfolge Contexte : - + Hintergrund: Barre de raccourcis configuration page title - + Schnellzugriffsleiste @@ -14571,17 +14581,17 @@ Andere Felder werden nicht verwendet. Folio, rien de sélectionné - + Seite, nichts ausgewählt Éléments sélectionnés - + Ausgewählte Elemente Conducteurs sélectionnés - + Ausgewählte Leiter @@ -18491,12 +18501,12 @@ die Variable "volta" kombiniert mit dem Wert "1745" lässt i Afficher dans le peigne de contacts tous les contacts esclaves définis par le maître, même ceux qui ne sont pas encore reliés, dans l'ordre défini par le maître - + Alle vom Master definierten Slave-Kontakte im Kontaktfeld anzeigen, auch diejenigen, die noch nicht verbunden sind, und zwar in der vom Master festgelegten Reihenfolge Afficher tous les esclaves définis par le maître - + Alle vom Master definierten Slaves anzeigen @@ -18641,17 +18651,17 @@ die Variable "volta" kombiniert mit dem Wert "1745" lässt i La requête est vide. - + Die Abfrage ist leer. Une seule requête SELECT est autorisée (le caractère ';' ne peut apparaître qu'à la toute fin). - + Es ist nur eine SELECT-Abfrage zulässig (das Zeichen „;“ darf nur ganz am Ende stehen). Seules les requêtes en lecture seule (SELECT ou WITH ... SELECT) sont autorisées. - + Es sind ausschließlich Leseabfragen (SELECT oder WITH ... SELECT) zulässig. From f15736bff74580ea2ce22eb4a848645801497fc5 Mon Sep 17 00:00:00 2001 From: plc-user <74435298+plc-user@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:39:04 +0200 Subject: [PATCH 43/45] Improve German wording --- lang/qet_de.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/qet_de.ts b/lang/qet_de.ts index c266e62dd..91abb7e19 100644 --- a/lang/qet_de.ts +++ b/lang/qet_de.ts @@ -791,7 +791,7 @@ Bemerkung: diese Optionen verhindern NICHT das automatische Nummerieren. Requête refusée - Antrag abgelehnt + Abfrage abgelehnt From 0d869b7e506a2e26ef12c7679d35c51042857a77 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Wed, 30 Sep 2026 07:05:54 +1300 Subject: [PATCH 44/45] Align the lines of a multi-line symbol text to its alignment The lines of a wrapped or multi-line dynamic text were always centred, whatever horizontal alignment the user picked, on the folio and in the symbol editor alike. Follow the horizontal alignment instead. The alignment still also sets the anchor point, as before, and saved positions are unchanged. Existing multi-line texts set to the default left alignment are now drawn left-aligned instead of centred. Issue #1155 Co-Authored-By: Claude Opus 5.5 (1M context) --- sources/editor/graphicspart/partdynamictextfield.cpp | 6 +++++- sources/qetgraphicsitem/dynamicelementtextitem.cpp | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/sources/editor/graphicspart/partdynamictextfield.cpp b/sources/editor/graphicspart/partdynamictextfield.cpp index c65c955df..9c26a70d6 100644 --- a/sources/editor/graphicspart/partdynamictextfield.cpp +++ b/sources/editor/graphicspart/partdynamictextfield.cpp @@ -47,7 +47,7 @@ PartDynamicTextField::PartDynamicTextField(QETElementEditor *editor, QGraphicsIt //Option when text is displayed in multiple line QTextOption option = document() -> defaultTextOption(); - option.setAlignment(Qt::AlignHCenter); + option.setAlignment(m_alignment & Qt::AlignHorizontal_Mask); option.setWrapMode(QTextOption::WordWrap); document() -> setDefaultTextOption(option); } @@ -462,6 +462,10 @@ void PartDynamicTextField::setPlainText(const QString &text) { void PartDynamicTextField::setAlignment(Qt::Alignment alignment) { m_alignment = alignment; + //Lines of a multi-line text follow the horizontal alignment + QTextOption option = document() -> defaultTextOption(); + option.setAlignment(m_alignment & Qt::AlignHorizontal_Mask); + document() -> setDefaultTextOption(option); emit alignmentChanged(m_alignment); } diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp index fda123da9..5c5578ed9 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp +++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp @@ -63,9 +63,17 @@ DynamicElementTextItem::DynamicElementTextItem(Element *parent_element) : //Option when text is displayed in multiple line QTextOption option = document()->defaultTextOption(); - option.setAlignment(Qt::AlignHCenter); + option.setAlignment(alignment() & Qt::AlignHorizontal_Mask); option.setWrapMode(QTextOption::WordWrap); document()->setDefaultTextOption(option); + + //Lines of a multi-line text follow the horizontal alignment + connect(this, &DiagramTextItem::alignmentChanged, [this](Qt::Alignment alignment) + { + QTextOption option = document()->defaultTextOption(); + option.setAlignment(alignment & Qt::AlignHorizontal_Mask); + document()->setDefaultTextOption(option); + }); } DynamicElementTextItem::~DynamicElementTextItem() From bae336ef5b984e5059a655dfec74dd2dde3c7845 Mon Sep 17 00:00:00 2001 From: plc-user <74435298+plc-user@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:57:43 +0200 Subject: [PATCH 45/45] =?UTF-8?q?Update=20define=5Fdefinitions.cmake=20?= =?UTF-8?q?=E2=80=93=20fix=20only=20whitespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/define_definitions.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/define_definitions.cmake b/cmake/define_definitions.cmake index c7f326abf..89ea2c6d4 100644 --- a/cmake/define_definitions.cmake +++ b/cmake/define_definitions.cmake @@ -63,7 +63,7 @@ message("QET_DIR :" ${QET_DIR}) message("GIT_COMMIT_SHA :" ${GIT_COMMIT_SHA}) if(BUILD_WITH_KF) - message("KF_GIT_TAG :" ${KF_GIT_TAG}) + message("KF_GIT_TAG :" ${KF_GIT_TAG}) else() add_definitions(-DBUILD_WITHOUT_KF) endif()