From fb350276241881598cb7134678d486b360557f3d Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Thu, 11 Jun 2026 12:57:28 +1200 Subject: [PATCH 1/2] CLI: add --info, --export-bom and --check-elements verification tools Extends the headless command-line interface with three read-only tools aimed at validating projects and element libraries (useful for batch import / migration pipelines): qelectrotech --info [output.json] qelectrotech --export-bom qelectrotech --check-elements - --info dumps a structural summary as JSON straight from QET's loaded model: per-diagram element / conductor counts, page size, and the number of unconnected ("free") terminals, plus project totals. Because it uses the real loader it reports what the editor actually sees. - --export-bom writes a bill of materials (one row per element) as CSV, querying the project's own element_nomenclature_view (the same source as the GUI BOM export). updateDB() is called first so the database is populated in a headless run. - --check-elements validates one .elmt file, or every .elmt under a directory (recursively), against the element schema: XML well-formed, root , a usable bounding box, and terminal count. Reports OK / WARN / FAIL per file and a summary; exit code is non-zero if any file fails. Verified against the full bundled collection (8483 elements): 0 false failures, agreeing with QET's own loader (e.g. a negative-height element it tolerates is a WARN, not a FAIL). run() is restructured to handle the differing argument arity (info takes an optional output, check-elements takes a path rather than a project). --- sources/cli_export.cpp | 272 +++++++++++++++++++++++++++++++++++++++-- sources/cli_export.h | 17 ++- 2 files changed, 274 insertions(+), 15 deletions(-) diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index c29fea8b0..44aa2b8d8 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -19,15 +19,26 @@ #include "bordertitleblock.h" #include "conductornumexport.h" +#include "dataBase/projectdatabase.h" #include "diagram.h" +#include "diagramcontext.h" +#include "qetgraphicsitem/element.h" +#include "qetgraphicsitem/terminal.h" #include "qetproject.h" #include "wiringlistexport.h" #include +#include +#include #include #include +#include +#include +#include #include #include +#include +#include #include #include @@ -36,7 +47,7 @@ namespace { QTextStream out(stdout); QTextStream err(stderr); -/// All export option flags, mapped to a short format name. +/// All CLI option flags, mapped to a short format name. const QHash &exportFlags() { static const QHash flags { @@ -45,6 +56,9 @@ const QHash &exportFlags() {"--export-svg", "svg"}, {"--export-cables", "cables"}, {"--export-wires", "wires"}, + {"--export-bom", "bom"}, + {"--info", "info"}, + {"--check-elements", "check"}, }; return flags; } @@ -195,6 +209,223 @@ int exportCsv(QETProject &project, const QString &format, const QString &output) return 0; } +/// Quote a field for CSV output (RFC-4180 style, ';' delimiter). +QString csvField(const QString &value) +{ + if (value.contains(';') || value.contains('"') + || value.contains('\n') || value.contains('\r')) { + QString v = value; + v.replace('"', "\"\""); + return '"' % v % '"'; + } + return value; +} + +/// Bill of materials: one row per element, key component-data fields. +/// Pulls from QET's own project database (the same source as the GUI BOM +/// export), so the output matches what the editor produces. +int exportBom(QETProject &project, const QString &output) +{ + // The project database is built lazily; force a (re)build before querying. + project.dataBase()->updateDB(); + + static const QStringList columns { + "label", "designation", "manufacturer", "manufacturer_reference", + "quantity", "location", "function", "title", "folio" + }; + + QSqlQuery query = project.dataBase()->newQuery( + "SELECT " % columns.join(", ") % + " FROM element_nomenclature_view ORDER BY label"); + if (!query.exec()) { + err << "BOM query failed: " << query.lastError().text() << "\n"; + return 1; + } + + QString csv = columns.join(";") % "\n"; + int rows = 0; + while (query.next()) { + QStringList values; + for (int i = 0; i < columns.size(); ++i) + values << csvField(query.value(i).toString()); + csv += values.join(";") % "\n"; + ++rows; + } + + QFile file(output); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + err << "Cannot open '" << output << "' for writing.\n"; + return 1; + } + QTextStream fout(&file); + fout << csv; + file.close(); + out << "Exported " << rows << " component(s) -> " << output << "\n"; + return 0; +} + +/// Count terminals on @p element that no conductor connects to. +int freeTerminals(Element *element) +{ + int free = 0; + const QList terminals = element->terminals(); + for (Terminal *t : terminals) + if (t->conductorsCount() == 0) + ++free; + return free; +} + +/// Structural ground-truth dump of a project, as JSON, to stdout (or a file). +/// Uses QET's own loaded model, so it reports what the editor actually sees: +/// per-page element / conductor counts and unconnected terminals. +int exportInfo(QETProject &project, const QString &output) +{ + const QList diagrams = project.diagrams(); + + int total_elements = 0, total_conductors = 0, total_free = 0; + QJsonArray pages; + int index = 0; + for (Diagram *diagram : diagrams) { + ++index; + const QList elements = diagram->elements(); + const int conductors = diagram->conductors().size(); + int page_free = 0; + for (Element *e : elements) + page_free += freeTerminals(e); + + const QRect r = diagramRect(diagram); + QJsonObject page; + page["index"] = index; + page["title"] = diagram->title(); + page["folio"] = QStringLiteral("%1 of %2") + .arg(index).arg(diagrams.size()); + page["width_px"] = r.width(); + page["height_px"] = r.height(); + page["elements"] = elements.size(); + page["conductors"] = conductors; + page["free_terminals"] = page_free; + pages.append(page); + + total_elements += elements.size(); + total_conductors += conductors; + total_free += page_free; + } + + QJsonObject root; + root["project"] = project.title(); + root["diagrams"] = diagrams.size(); + root["elements"] = total_elements; + root["conductors"] = total_conductors; + root["free_terminals"] = total_free; + root["pages"] = pages; + + const QByteArray json = + QJsonDocument(root).toJson(QJsonDocument::Indented); + + if (output.isEmpty()) { + out << QString::fromUtf8(json); + } else { + QFile file(output); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + err << "Cannot open '" << output << "' for writing.\n"; + return 1; + } + file.write(json); + file.close(); + out << "Wrote project info -> " << output << "\n"; + } + return 0; +} + +/// Validate one .elmt file against QET's element schema. +/// @return 0 = OK, 1 = warning (loads but suspicious), 2 = failure. +int checkOneElement(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + out << "FAIL " << path << " (cannot open)\n"; + return 2; + } + QDomDocument doc; + QString error; + int line = 0; + if (!doc.setContent(&file, &error, &line)) { + file.close(); + out << "FAIL " << path << " (XML error line " + << line << ": " << error << ")\n"; + return 2; + } + file.close(); + + const QDomElement root = doc.documentElement(); + if (root.tagName() != "definition" || root.attribute("type") != "element") { + out << "FAIL " << path << " (root is not )\n"; + return 2; + } + + bool w_ok = false, h_ok = false; + const double w = root.attribute("width").toDouble(&w_ok); + const double h = root.attribute("height").toDouble(&h_ok); + if (!w_ok || !h_ok || w == 0 || h == 0) { + out << "FAIL " << path << " (missing/zero bounding box " + << root.attribute("width") << "x" + << root.attribute("height") << ")\n"; + return 2; + } + + const int terminals = root.elementsByTagName("terminal").count(); + + // Negative dimensions are malformed but QET still loads them; surface as a + // warning rather than a failure so this agrees with QET's own loader. + if (w < 0 || h < 0) { + out << "WARN " << path << " (negative bounding box " + << w << "x" << h << ", " << terminals << " terminals)\n"; + return 1; + } + + if (terminals == 0) { + out << "WARN " << path << " (loads, but 0 terminals)\n"; + return 1; + } + + out << "OK " << path << " (" << terminals << " terminals)\n"; + return 0; +} + +/// Validate a single .elmt file or every .elmt under a directory. +int checkElements(const QString &path) +{ + QStringList files; + const QFileInfo info(path); + if (info.isDir()) { + QDirIterator it(path, {"*.elmt"}, QDir::Files, + QDirIterator::Subdirectories); + while (it.hasNext()) + files << it.next(); + files.sort(); + } else if (info.isFile()) { + files << path; + } else { + err << "Not found: " << path << "\n"; + return 2; + } + + if (files.isEmpty()) { + err << "No .elmt files found under: " << path << "\n"; + return 2; + } + + int warnings = 0, failures = 0; + for (const QString &f : files) { + const int r = checkOneElement(f); + if (r == 1) ++warnings; + else if (r == 2) ++failures; + } + out << files.size() << " file(s), " << warnings + << " warning(s), " << failures << " failure(s)\n"; + return failures > 0 ? 1 : 0; +} + } // anonymous namespace namespace CLIExport { @@ -209,21 +440,31 @@ bool isExportRequest(const QStringList &args) int run(const QStringList &args) { - QString flag, input, output; + QString flag; + QStringList rest; for (int i = 0; i < args.size(); ++i) { if (exportFlags().contains(args.at(i))) { flag = args.at(i); - if (i + 2 < args.size()) { - input = args.at(i + 1); - output = args.at(i + 2); - } + for (int j = i + 1; j < args.size(); ++j) + rest << args.at(j); break; } } + const QString format = exportFlags().value(flag); - if (input.isEmpty() || output.isEmpty()) { - err << "Usage: qelectrotech " << flag - << " \n"; + // --check-elements operates on an element file/directory, not a project. + if (format == "check") { + if (rest.isEmpty()) { + err << "Usage: qelectrotech --check-elements " + "\n"; + return 2; + } + return checkElements(rest.at(0)); + } + + const QString input = rest.value(0); + if (input.isEmpty()) { + err << "Usage: qelectrotech " << flag << " \n"; return 2; } if (!QFileInfo::exists(input)) { @@ -238,11 +479,22 @@ int run(const QStringList &args) return 1; } - const QString format = exportFlags().value(flag); + // --info writes JSON to stdout, or to an optional output file. + if (format == "info") + return exportInfo(project, rest.value(1)); + + const QString output = rest.value(1); + if (output.isEmpty()) { + err << "Usage: qelectrotech " << flag + << " \n"; + return 2; + } if (format == "pdf") return exportPdf(project, output); if (format == "cables" || format == "wires") return exportCsv(project, format, output); + if (format == "bom") + return exportBom(project, output); return exportImages(project, format, output); } diff --git a/sources/cli_export.h b/sources/cli_export.h index 9ac699307..149a45107 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -42,16 +42,23 @@ namespace CLIExport { @return process exit code (0 on success). Usage: - qelectrotech --export-pdf - qelectrotech --export-png - qelectrotech --export-svg - qelectrotech --export-cables - qelectrotech --export-wires + qelectrotech --export-pdf + qelectrotech --export-png + qelectrotech --export-svg + qelectrotech --export-cables + qelectrotech --export-wires + qelectrotech --export-bom + qelectrotech --info [output.json] + qelectrotech --check-elements PDF: one multi-page document (one diagram per page). PNG/SVG: one file per diagram, named /_.<ext>. cables: wiring list (one row per conductor) as CSV. wires: list of distinct wire numbers as CSV. + bom: bill of materials (one row per element) as CSV. + info: structural project summary as JSON (stdout, or a file) — + per-page element / conductor counts and unconnected terminals. + check-elements: validate .elmt file(s) against the element schema. */ int run(const QStringList &args); From b6e4cd4786c24d3b276b064df730b0025bbc2a15 Mon Sep 17 00:00:00 2001 From: Shane Ringrose <shane@ringrose.co.nz> Date: Thu, 11 Jun 2026 13:20:20 +1200 Subject: [PATCH 2/2] CLI: add --export-nets, --export-links and --resave Three more read-only command-line tools for verifying connectivity and cross-reference intelligence (useful for import / migration pipelines): qelectrotech --export-nets <project.qet> <output.json> qelectrotech --export-links <project.qet> <output.csv> qelectrotech --resave <project.qet> <output.qet> - --export-nets walks Conductor::relatedPotentialConductors() to group every electrically-connected terminal into a net (potential), following folio reports and terminal blocks across all folios. Output is JSON: per net, the wire number and the list of {element, terminal, folio}. This is the connectivity ground truth. - --export-links reports each linkable element (master/slave/report/ terminal), its link type and the elements it links to, flagging masters/slaves with no link as UNRESOLVED. Verifies coil<->contact cross-references. Verified on examples/industrial.qet: 436 linkable (76 master, 41 slave, ...), 37 unresolved. - --resave loads the project and writes its XML back out, so an external diff can reveal markup QET silently normalises on load (tolerated-but-invalid XML). Round-trip verified: the re-saved project reloads with identical diagram/element/conductor counts. --- sources/cli_export.cpp | 177 +++++++++++++++++++++++++++++++++++++++++ sources/cli_export.h | 7 ++ 2 files changed, 184 insertions(+) diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index 44aa2b8d8..a6b85e580 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -19,9 +19,11 @@ #include "bordertitleblock.h" #include "conductornumexport.h" +#include "conductorproperties.h" #include "dataBase/projectdatabase.h" #include "diagram.h" #include "diagramcontext.h" +#include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/element.h" #include "qetgraphicsitem/terminal.h" #include "qetproject.h" @@ -37,6 +39,7 @@ #include <QJsonObject> #include <QPainter> #include <QPdfWriter> +#include <QSet> #include <QSqlError> #include <QSqlQuery> #include <QSvgGenerator> @@ -57,12 +60,22 @@ const QHash<QString, QString> &exportFlags() {"--export-cables", "cables"}, {"--export-wires", "wires"}, {"--export-bom", "bom"}, + {"--export-nets", "nets"}, + {"--export-links", "links"}, {"--info", "info"}, {"--check-elements", "check"}, + {"--resave", "resave"}, }; return flags; } +/// Device tag of an element ("K1", "Q55"), falling back to its name. +QString elementLabel(Element *element) +{ + const QString label = element->elementInformations()["label"].toString(); + return label.isEmpty() ? element->name() : label; +} + /// Pixel rect of a diagram's border + title block (the printable page area). QRect diagramRect(Diagram *diagram) { @@ -426,6 +439,164 @@ int checkElements(const QString &path) return failures > 0 ? 1 : 0; } +/// Map every element in the project to its 1-based folio (page) position. +QHash<Element *, int> folioIndex(QETProject &project) +{ + QHash<Element *, int> folio; + int index = 0; + const QList<Diagram *> diagrams = project.diagrams(); + for (Diagram *diagram : diagrams) { + ++index; + const QList<Element *> elements = diagram->elements(); + for (Element *e : elements) + folio.insert(e, index); + } + return folio; +} + +/// Electrical nets: groups of terminals joined into one potential. +/// Walks QET's own potential graph, so each net is a connected component +/// of terminals across all folios. The ground truth for connectivity. +int exportNets(QETProject &project, const QString &output) +{ + const QHash<Element *, int> folio = folioIndex(project); + + QList<Conductor *> all_conductors; + const QList<Diagram *> diagrams = project.diagrams(); + for (Diagram *diagram : diagrams) + all_conductors << diagram->conductors(); + + QSet<Conductor *> visited; + QJsonArray nets; + int net_no = 0; + for (Conductor *c : all_conductors) { + if (visited.contains(c)) + continue; + + // The whole potential this conductor belongs to. relatedPotential- + // Conductors() also fills t_list with every terminal in the net + // (following folio reports and terminal blocks too). + QList<Terminal *> t_list; + QSet<Conductor *> group = c->relatedPotentialConductors(true, &t_list); + group.insert(c); + for (Conductor *g : group) + visited.insert(g); + if (c->terminal1) t_list << c->terminal1; + if (c->terminal2) t_list << c->terminal2; + + // Wire number: smallest non-empty conductor text (deterministic). + QStringList wire_nos; + for (Conductor *g : group) + if (!g->properties().text.isEmpty()) + wire_nos << g->properties().text; + wire_nos.sort(); + + ++net_no; + QJsonArray terminals; + QSet<Terminal *> seen; + for (Terminal *t : t_list) { + if (!t || seen.contains(t)) + continue; + seen.insert(t); + Element *pe = t->parentElement(); + QJsonObject to; + to["element"] = pe ? elementLabel(pe) : QString(); + to["terminal"] = t->name(); + to["folio"] = pe ? folio.value(pe, 0) : 0; + terminals.append(to); + } + QJsonObject net; + net["net"] = net_no; + net["wire_no"] = wire_nos.value(0); + net["terminals"] = terminals; + nets.append(net); + } + + QJsonObject root; + root["project"] = project.title(); + root["nets"] = nets.size(); + root["list"] = nets; + + QFile file(output); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + err << "Cannot open '" << output << "' for writing.\n"; + return 1; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + file.close(); + out << "Exported " << nets.size() << " net(s) -> " << output << "\n"; + return 0; +} + +/// Cross-references: each linkable element (coil / contact / report) and the +/// elements it links to, flagging masters/slaves with no link as unresolved. +int exportLinks(QETProject &project, const QString &output) +{ + const QHash<Element *, int> folio = folioIndex(project); + + QString csv("element;link_type;linked_to;folio;status\n"); + int linkable = 0, unresolved = 0; + + const QList<Diagram *> diagrams = project.diagrams(); + for (Diagram *diagram : diagrams) { + const QList<Element *> elements = diagram->elements(); + for (Element *e : elements) { + if (e->linkType() == Element::Simple) + continue; + ++linkable; + + const QList<Element *> linked = e->linkedElements(); + QStringList names; + for (Element *le : linked) + names << elementLabel(le) % "(f" + % QString::number(folio.value(le, 0)) % ")"; + + QString status = "linked"; + if ((e->linkType() == Element::Master + || e->linkType() == Element::Slave) + && linked.isEmpty()) { + status = "UNRESOLVED"; + ++unresolved; + } + + csv += csvField(elementLabel(e)) % ";" + % e->linkTypeToString() % ";" + % csvField(names.join(", ")) % ";" + % QString::number(folio.value(e, 0)) % ";" + % status % "\n"; + } + } + + QFile file(output); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + err << "Cannot open '" << output << "' for writing.\n"; + return 1; + } + QTextStream fout(&file); + fout << csv; + file.close(); + out << "Exported " << linkable << " linkable element(s), " + << unresolved << " unresolved -> " << output << "\n"; + return 0; +} + +/// Round-trip: load the project and write its XML back out, so an external +/// diff can reveal markup QET silently normalises (tolerated-but-invalid XML). +int resaveProject(QETProject &project, const QString &output) +{ + const QDomDocument doc = project.toXml(); + QFile file(output); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + err << "Cannot open '" << output << "' for writing.\n"; + return 1; + } + QTextStream fout(&file); + fout << doc.toString(4); + file.close(); + out << "Re-saved project -> " << output << "\n"; + return 0; +} + } // anonymous namespace namespace CLIExport { @@ -495,6 +666,12 @@ int run(const QStringList &args) return exportCsv(project, format, output); if (format == "bom") return exportBom(project, output); + if (format == "nets") + return exportNets(project, output); + if (format == "links") + return exportLinks(project, output); + if (format == "resave") + return resaveProject(project, output); return exportImages(project, format, output); } diff --git a/sources/cli_export.h b/sources/cli_export.h index 149a45107..5abe194ca 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -48,17 +48,24 @@ namespace CLIExport { qelectrotech --export-cables <project.qet> <output.csv> qelectrotech --export-wires <project.qet> <output.csv> qelectrotech --export-bom <project.qet> <output.csv> + qelectrotech --export-nets <project.qet> <output.json> + qelectrotech --export-links <project.qet> <output.csv> qelectrotech --info <project.qet> [output.json] qelectrotech --check-elements <element.elmt | directory> + qelectrotech --resave <project.qet> <output.qet> PDF: one multi-page document (one diagram per page). PNG/SVG: one file per diagram, named <output_dir>/<NN>_<title>.<ext>. cables: wiring list (one row per conductor) as CSV. wires: list of distinct wire numbers as CSV. bom: bill of materials (one row per element) as CSV. + nets: electrical nets (connected-terminal groups) as JSON. + links: element cross-references (coil/contact) as CSV, with + unresolved links flagged. info: structural project summary as JSON (stdout, or a file) — per-page element / conductor counts and unconnected terminals. check-elements: validate .elmt file(s) against the element schema. + resave: load and rewrite the project XML (round-trip integrity). */ int run(const QStringList &args);