mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-09-26 11:54:14 +02:00
b034b1a634
The wiring_list_view added by this slice was only reachable through the GUI,
which meant the one thing worth proving about it -- that it still describes
the project -- could not be checked without a person clicking. This is the
same shape as the existing --export-bom, which reads
element_nomenclature_view, and it makes the view verifiable in CI.
It also makes this slice useful on its own: a from-to wiring list is a thing
people want as a CSV, and it no longer waits on the dialog in the next slice.
There is deliberately an overlap with --export-cables, which produces the same
logical list from the document XML rather than the database. Keeping both is
the point: running them and diffing them is a direct check that the cache and
the document still agree, which nothing else in the codebase can do.
Measured on the example corpus, the two also differ in what they can actually
fill in. Rows carrying any endpoint data:
--export-cables --export-wiring
industrial.qet 0 / 671 541 / 671
m_000.qet 0 / 457 362 / 457
affuteuse_250h.qet 0 / 263 197 / 263
tremie_vibrante.qet 0 / 77 61 / 77
tableau_domestique.qet 58 / 130 104 / 130
Both return a row per conductor; the XML-derived one leaves the component and
terminal columns empty on the older projects, and emits an unresolved "%id"
in its folio column. That is not an argument for removing it -- it carries
columns the view does not, and it is the independent second opinion -- but it
does mean the database path is the one with the data on the projects people
actually have.
The terminal-name columns come back empty on most projects. That is absent
source data, not a loss in transit: tableau_domestique.qet has no terminal
name on 457 of 457 terminals, and industrial.qet stores the "_" placeholder
on 1421 of 1790.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
889 lines
26 KiB
C++
889 lines
26 KiB
C++
/*
|
|
Copyright 2006-2025 The QElectroTech Team
|
|
This file is part of QElectroTech.
|
|
|
|
QElectroTech is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 2 of the License, or
|
|
(at your option) any later version.
|
|
|
|
QElectroTech is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
#include "cli_export.h"
|
|
|
|
#include "bordertitleblock.h"
|
|
#include "conductornumexport.h"
|
|
#include "conductorproperties.h"
|
|
#include "dataBase/projectdatabase.h"
|
|
#include "diagram.h"
|
|
#include "diagramcontext.h"
|
|
#include "pdf_links.h"
|
|
#include "qetgraphicsitem/conductor.h"
|
|
#include "qetgraphicsitem/element.h"
|
|
#include "qetgraphicsitem/terminal.h"
|
|
#include "qetproject.h"
|
|
#include "titleblockproperties.h"
|
|
#include "wiringlistexport.h"
|
|
|
|
// Private Qt PDF engine for drawHyperlink() — see pdf_links / projectprintwindow.
|
|
#include <private/qpdf_p.h>
|
|
|
|
#include <QDir>
|
|
#include <QDirIterator>
|
|
#include <QDomDocument>
|
|
#include <QDate>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QMap>
|
|
#include <QPageLayout>
|
|
#include <QPair>
|
|
#include <QPainter>
|
|
#include <QPdfWriter>
|
|
#include <QSet>
|
|
#include <QSqlError>
|
|
#include <QSqlQuery>
|
|
#include <QSvgGenerator>
|
|
#include <QTextStream>
|
|
#include <QTransform>
|
|
|
|
namespace {
|
|
|
|
QTextStream out(stdout);
|
|
QTextStream err(stderr);
|
|
|
|
/// All CLI option flags, mapped to a short format name.
|
|
const QHash<QString, QString> &exportFlags()
|
|
{
|
|
static const QHash<QString, QString> flags {
|
|
{"--export-pdf", "pdf"},
|
|
{"--export-png", "png"},
|
|
{"--export-svg", "svg"},
|
|
{"--export-cables", "cables"},
|
|
{"--export-wires", "wires"},
|
|
{"--export-bom", "bom"},
|
|
{"--export-wiring", "wiring"},
|
|
{"--export-nets", "nets"},
|
|
{"--export-links", "links"},
|
|
{"--info", "info"},
|
|
{"--check-elements", "check"},
|
|
{"--resave", "resave"},
|
|
{"--set-titleblock", "settb"},
|
|
};
|
|
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)
|
|
{
|
|
QRectF r = diagram->border_and_titleblock.borderAndTitleBlockRect();
|
|
r.adjust(0, 0, 1, 1); // include the 1px border line
|
|
return r.toAlignedRect();
|
|
}
|
|
|
|
/// A filesystem-safe per-diagram file stem: "01_Title".
|
|
QString diagramStem(Diagram *diagram, int index)
|
|
{
|
|
QString title = diagram->title();
|
|
title.replace(QRegularExpression("[^\\w \\-]"), "_");
|
|
title = title.simplified();
|
|
if (title.isEmpty())
|
|
title = "diagram";
|
|
return QStringLiteral("%1_%2")
|
|
.arg(index, 2, 10, QChar('0'))
|
|
.arg(title);
|
|
}
|
|
|
|
/// Render @p diagram into @p painter, fitting @p target to the page rect.
|
|
void renderDiagram(Diagram *diagram, QPainter &painter, const QRectF &target)
|
|
{
|
|
const QRect source = diagramRect(diagram);
|
|
// Export without the editor grid: drawBackground() only paints it when
|
|
// draw_grid_ is set (default true), so toggle it off around the render
|
|
// and restore it afterwards.
|
|
const bool was_drawing_grid = diagram->displayGrid();
|
|
const bool was_drawing_guides = diagram->displayGuides();
|
|
diagram->setDisplayGrid(false);
|
|
diagram->setDisplayGuides(false);
|
|
diagram->render(&painter, target, source, Qt::KeepAspectRatio);
|
|
diagram->setDisplayGrid(was_drawing_grid);
|
|
diagram->setDisplayGuides(was_drawing_guides);
|
|
}
|
|
|
|
int exportPdf(QETProject &project, const QString &output)
|
|
{
|
|
const QList<Diagram *> diagrams = project.diagrams();
|
|
if (diagrams.isEmpty()) {
|
|
err << "No diagrams to export.\n";
|
|
return 1;
|
|
}
|
|
|
|
// Page numbers (1-based) for cross-reference hyperlink targets: each
|
|
// diagram is exactly one page in the CLI export (no tiling).
|
|
QMap<Diagram *, int> pageMap;
|
|
for (int i = 0; i < diagrams.size(); ++i)
|
|
pageMap.insert(diagrams.at(i), i + 1);
|
|
|
|
QPdfWriter writer(output);
|
|
writer.setCreator("QElectroTech");
|
|
writer.setResolution(96);
|
|
|
|
QPainter painter;
|
|
bool first = true;
|
|
for (Diagram *diagram : diagrams) {
|
|
const QRect r = diagramRect(diagram);
|
|
// Match the page to the diagram (in points: 1px @ 96dpi = 0.75pt).
|
|
const QPageSize page(QSizeF(r.width() * 72.0 / 96.0,
|
|
r.height() * 72.0 / 96.0),
|
|
QPageSize::Point);
|
|
writer.setPageSize(page);
|
|
writer.setPageMargins(QMarginsF(0, 0, 0, 0));
|
|
|
|
if (first) {
|
|
if (!painter.begin(&writer)) {
|
|
err << "Cannot open '" << output << "' for writing.\n";
|
|
return 1;
|
|
}
|
|
first = false;
|
|
} else {
|
|
writer.newPage();
|
|
}
|
|
const QRectF target(0, 0,
|
|
writer.width(), writer.height());
|
|
renderDiagram(diagram, painter, target);
|
|
|
|
// Inject clickable cross-reference / folio-report hyperlinks for this
|
|
// page. The geometry is rebuilt from the QPdfWriter (not a QPrinter):
|
|
// render() anchors the diagram top-left with KeepAspectRatio, and the
|
|
// page is sized to the diagram so the scale is ~1.
|
|
if (auto *engine = dynamic_cast<QPdfEngine *>(painter.paintEngine())) {
|
|
const QRectF source(r);
|
|
const qreal s = qMin(target.width() / source.width(),
|
|
target.height() / source.height());
|
|
QTransform fit;
|
|
fit.translate(target.x(), target.y());
|
|
fit.scale(s, s);
|
|
fit.translate(-source.x(), -source.y());
|
|
|
|
// Device pixels -> PDF points, replicating the engine's page matrix
|
|
// (72/resolution scale + Y flip; zero margins -> no paint offset).
|
|
const qreal pt_scale = 72.0 / writer.resolution();
|
|
const qreal fullH_pt = writer.pageLayout().fullRectPoints().height();
|
|
const bool fullPageMode =
|
|
(writer.pageLayout().mode() == QPageLayout::FullPageMode);
|
|
const QRect paintPx =
|
|
writer.pageLayout().paintRectPixels(writer.resolution());
|
|
|
|
PdfLinks::PageGeometry geom;
|
|
geom.sceneToDevice = fit;
|
|
geom.target = target;
|
|
geom.pageBounds = QRectF(0, 0, target.width(), target.height());
|
|
geom.devToPdf = [=](const QPointF &d) -> QPointF {
|
|
qreal dx = d.x(), dy = d.y();
|
|
if (!fullPageMode) { dx += paintPx.left(); dy += paintPx.top(); }
|
|
return QPointF(pt_scale * dx, fullH_pt - pt_scale * dy);
|
|
};
|
|
geom.sourceRectOf = [](Diagram *dg) {
|
|
return QRectF(diagramRect(dg));
|
|
};
|
|
PdfLinks::injectCrossRefLinks(engine, diagram, geom, pageMap, output);
|
|
}
|
|
}
|
|
painter.end();
|
|
|
|
// Rewrite the URI link annotations into native internal GoTo actions, so
|
|
// the cross-references jump inside the document in any PDF viewer.
|
|
PdfLinks::convertUriToGoTo(output);
|
|
|
|
out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n";
|
|
return 0;
|
|
}
|
|
|
|
int exportImages(QETProject &project, const QString &format,
|
|
const QString &out_dir)
|
|
{
|
|
const QList<Diagram *> diagrams = project.diagrams();
|
|
if (diagrams.isEmpty()) {
|
|
err << "No diagrams to export.\n";
|
|
return 1;
|
|
}
|
|
QDir().mkpath(out_dir);
|
|
|
|
int index = 0;
|
|
for (Diagram *diagram : diagrams) {
|
|
++index;
|
|
const QRect r = diagramRect(diagram);
|
|
const QString path = QDir(out_dir).filePath(
|
|
diagramStem(diagram, index) + "." + format);
|
|
|
|
if (format == "svg") {
|
|
QSvgGenerator gen;
|
|
gen.setFileName(path);
|
|
gen.setSize(r.size());
|
|
gen.setViewBox(QRect(0, 0, r.width(), r.height()));
|
|
gen.setTitle(diagram->title());
|
|
QPainter painter(&gen);
|
|
renderDiagram(diagram, painter, QRectF(QPointF(0, 0), r.size()));
|
|
painter.end();
|
|
} else { // png
|
|
QImage image(r.size(), QImage::Format_ARGB32);
|
|
image.fill(Qt::white);
|
|
QPainter painter(&image);
|
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
|
renderDiagram(diagram, painter, QRectF(QPointF(0, 0), r.size()));
|
|
painter.end();
|
|
if (!image.save(path)) {
|
|
err << "Failed to write '" << path << "'.\n";
|
|
return 1;
|
|
}
|
|
}
|
|
out << " " << path << "\n";
|
|
}
|
|
out << "Exported " << diagrams.size() << " diagram(s) -> " << out_dir << "\n";
|
|
return 0;
|
|
}
|
|
|
|
int exportCsv(QETProject &project, const QString &format, const QString &output)
|
|
{
|
|
QString csv;
|
|
if (format == "cables") {
|
|
WiringListExport wle(&project, nullptr);
|
|
csv = wle.toCsvString();
|
|
} else { // wires
|
|
ConductorNumExport cne(&project, nullptr);
|
|
csv = cne.wiresNum();
|
|
}
|
|
if (csv.isEmpty()) {
|
|
err << "Nothing to export (empty list).\n";
|
|
return 1;
|
|
}
|
|
|
|
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 " << format << " list -> " << output << "\n";
|
|
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<Terminal *> 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<Diagram *> 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<Element *> 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;
|
|
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
|
|
if (const auto result = doc.setContent(&file); !result) {
|
|
file.close();
|
|
out << "FAIL " << path << " (XML error line "
|
|
<< result.errorLine << ": " << result.errorMessage << ")\n";
|
|
return 2;
|
|
}
|
|
#else
|
|
QString error;
|
|
int line = 0;
|
|
if (!doc.setContent(&file, &error, &line)) {
|
|
file.close();
|
|
out << "FAIL " << path << " (XML error line "
|
|
<< line << ": " << error << ")\n";
|
|
return 2;
|
|
}
|
|
#endif
|
|
file.close();
|
|
|
|
const QDomElement root = doc.documentElement();
|
|
if (root.tagName() != "definition" || root.attribute("type") != "element") {
|
|
out << "FAIL " << path << " (root is not <definition type=\"element\">)\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;
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// From-to wiring list: one row per conductor, each endpoint resolved to its
|
|
/// element label and terminal name.
|
|
///
|
|
/// Reads wiring_list_view out of the project database. --export-cables produces
|
|
/// the same logical list from the document XML instead, and the two are meant
|
|
/// to agree: running both and diffing them is a direct check that the database
|
|
/// still describes the project, which is otherwise only observable through the
|
|
/// GUI.
|
|
int exportWiring(QETProject &project, const QString &output)
|
|
{
|
|
// The project database is built lazily; force a (re)build before querying.
|
|
project.dataBase()->updateDB();
|
|
|
|
static const QStringList columns {
|
|
"wire_number", "from_element_label", "from_terminal",
|
|
"to_element_label", "to_terminal", "diagram_position", "conductor_uuid"
|
|
};
|
|
|
|
QSqlQuery query = project.dataBase()->newQuery(
|
|
"SELECT " % columns.join(", ") %
|
|
" FROM wiring_list_view ORDER BY diagram_position, wire_number");
|
|
if (!query.exec()) {
|
|
err << "Wiring list 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 << " conductor(s) -> " << output << "\n";
|
|
return 0;
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
/// Stamp title-block fields onto every folio (and the project default), then
|
|
/// save. Each assignment is "key=value". Standard keys map to the documented
|
|
/// title-block fields; "date=today" uses the current date; any other key is
|
|
/// stored as a custom title-block field. Aimed at CI/revision workflows
|
|
/// (e.g. set revision + date before exporting a new revision).
|
|
int setTitleBlock(QETProject &project, const QString &output,
|
|
const QStringList &assignments)
|
|
{
|
|
if (assignments.isEmpty()) {
|
|
err << "No field assignments given (expected key=value).\n";
|
|
return 2;
|
|
}
|
|
|
|
// Parse "key=value" assignments up front so a bad one fails before writing.
|
|
QList<QPair<QString, QString>> fields;
|
|
for (const QString &a : assignments) {
|
|
const int eq = a.indexOf('=');
|
|
if (eq <= 0) {
|
|
err << "Bad assignment '" << a << "' (expected key=value).\n";
|
|
return 2;
|
|
}
|
|
const QString key = a.left(eq);
|
|
const QString val = a.mid(eq + 1);
|
|
if (key.compare("date", Qt::CaseInsensitive) == 0
|
|
&& val.compare("today", Qt::CaseInsensitive) != 0
|
|
&& !QDate::fromString(val, Qt::ISODate).isValid()) {
|
|
err << "Bad date '" << val << "' (expected YYYY-MM-DD or 'today').\n";
|
|
return 2;
|
|
}
|
|
fields << qMakePair(key, val);
|
|
}
|
|
|
|
auto apply = [&](TitleBlockProperties &p) {
|
|
for (const auto &f : fields) {
|
|
const QString k = f.first.toLower();
|
|
const QString &v = f.second;
|
|
if (k == "title") p.title = v;
|
|
else if (k == "author") p.author = v;
|
|
else if (k == "filename") p.filename = v;
|
|
else if (k == "plant") p.plant = v;
|
|
else if (k == "location") p.locmach = v;
|
|
else if (k == "revision") p.indexrev = v;
|
|
else if (k == "version") p.version = v;
|
|
else if (k == "date") {
|
|
p.date = (v.compare("today", Qt::CaseInsensitive) == 0)
|
|
? QDate::currentDate()
|
|
: QDate::fromString(v, Qt::ISODate);
|
|
// An explicit date is only honoured when the folio is in
|
|
// "use the date value" mode (not "now"/"null").
|
|
p.useDate = TitleBlockProperties::UseDateValue;
|
|
}
|
|
else // unknown key -> custom title-block field
|
|
p.context.addValue(f.first, v);
|
|
}
|
|
};
|
|
|
|
// Project default (the template applied to new folios).
|
|
TitleBlockProperties def = project.defaultTitleBlockProperties();
|
|
apply(def);
|
|
project.setDefaultTitleBlockProperties(def);
|
|
|
|
// Every existing folio's own title block.
|
|
int folios = 0;
|
|
const QList<Diagram *> diagrams = project.diagrams();
|
|
for (Diagram *diagram : diagrams) {
|
|
TitleBlockProperties p =
|
|
diagram->border_and_titleblock.exportTitleBlock();
|
|
apply(p);
|
|
diagram->border_and_titleblock.importTitleBlock(p);
|
|
++folios;
|
|
}
|
|
|
|
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 << "Stamped " << fields.size() << " field(s) on "
|
|
<< folios << " folio(s) -> " << output << "\n";
|
|
return 0;
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
namespace CLIExport {
|
|
|
|
bool isExportRequest(const QStringList &args)
|
|
{
|
|
for (const QString &a : args)
|
|
if (exportFlags().contains(a))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
int run(const QStringList &args)
|
|
{
|
|
QString flag;
|
|
QStringList rest;
|
|
for (int i = 0; i < args.size(); ++i) {
|
|
if (exportFlags().contains(args.at(i))) {
|
|
flag = args.at(i);
|
|
for (int j = i + 1; j < args.size(); ++j)
|
|
rest << args.at(j);
|
|
break;
|
|
}
|
|
}
|
|
const QString format = exportFlags().value(flag);
|
|
|
|
// --check-elements operates on an element file/directory, not a project.
|
|
if (format == "check") {
|
|
if (rest.isEmpty()) {
|
|
err << "Usage: qelectrotech --check-elements "
|
|
"<element.elmt | directory>\n";
|
|
return 2;
|
|
}
|
|
return checkElements(rest.at(0));
|
|
}
|
|
|
|
const QString input = rest.value(0);
|
|
if (input.isEmpty()) {
|
|
err << "Usage: qelectrotech " << flag << " <project.qet> <output>\n";
|
|
return 2;
|
|
}
|
|
if (!QFileInfo::exists(input)) {
|
|
err << "Project not found: " << input << "\n";
|
|
return 2;
|
|
}
|
|
|
|
QETProject project(input);
|
|
if (project.state() != QETProject::Ok) {
|
|
err << "Failed to open project: " << input
|
|
<< " (state " << project.state() << ")\n";
|
|
return 1;
|
|
}
|
|
|
|
// --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
|
|
<< " <project.qet> <output>\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);
|
|
if (format == "wiring")
|
|
return exportWiring(project, output);
|
|
if (format == "nets")
|
|
return exportNets(project, output);
|
|
if (format == "links")
|
|
return exportLinks(project, output);
|
|
if (format == "resave")
|
|
return resaveProject(project, output);
|
|
if (format == "settb")
|
|
return setTitleBlock(project, output, rest.mid(2));
|
|
return exportImages(project, format, output);
|
|
}
|
|
|
|
} // namespace CLIExport
|