From fb350276241881598cb7134678d486b360557f3d Mon Sep 17 00:00:00 2001 From: Shane Ringrose Date: Thu, 11 Jun 2026 12:57:28 +1200 Subject: [PATCH] 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);