Files
qelectrotech-source-mirror/sources/cli_export.cpp
T
ispyisail e345d4e5d0 Give each end of the wiring export its terminal index and uuid
Most shipped symbols leave their terminals unnamed, so --export-wiring
wrote both terminal columns empty and a reader had to work each end out
from the .qet: terminal uuid, then its place in the definition, then the
index. The terminal table now keeps the index the scripting API's
addConductor() takes, from both fills of the database (the document and
the folios give the same value: Element::parseTerminal()'s sort, top to
bottom then left to right), and wiring_list_view and the CSV gain
from/to_terminal_index and from/to_terminal_uuid as their last columns.

The index is left empty for two terminals at one point, where the sort
is not stable and the index is not defined; the uuid tells them apart.
The wiring list dialog reads named columns and is unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 22:30:49 +13:00

1011 lines
32 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 "bomexport.h"
#include "bordertitleblock.h"
#include "conductornumexport.h"
#include "conductorproperties.h"
#include "dataBase/projectdatabase.h"
#include "diagram.h"
#include "diagramcontext.h"
#include "editor/terminalnamecheck.h"
#include "dxfexport.h"
#include "exportproperties.h"
#include "pdf_links.h"
#include "qetgraphicsitem/conductor.h"
#include "qetgraphicsitem/diagramimageitem.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 <QSaveFile>
#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-dxf", "dxf"},
{"--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.
/// @p showTerminals: paint terminal markers (red stroke + blue docking
/// dot) and terminal names, as the interactive editor does. Off by
/// default — Terminal::paint() draws them whenever the diagram's
/// drawTerminals()/drawTerminalNames() flags are set (default true, so
/// headless export used to ship them as editor UI); the GUI export
/// dialog already defaults to clearing them via
/// Diagram::applyProperties(). Pass true (--show-terminals) to keep
/// them, e.g. to visually debug an unconnected pin.
void renderDiagram(Diagram *diagram, QPainter &painter, const QRectF &target,
bool showTerminals = false)
{
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();
const bool was_drawing_terminals = diagram->drawTerminals();
const bool was_drawing_terminal_names = diagram->drawTerminalNames();
diagram->setDisplayGrid(false);
diagram->setDisplayGuides(false);
diagram->setDrawTerminals(showTerminals);
diagram->setDrawTerminalNames(showTerminals);
diagram->render(&painter, target, source, Qt::KeepAspectRatio);
diagram->setDisplayGrid(was_drawing_grid);
diagram->setDisplayGuides(was_drawing_guides);
diagram->setDrawTerminals(was_drawing_terminals);
diagram->setDrawTerminalNames(was_drawing_terminal_names);
}
int exportPdf(QETProject &project, const QString &output,
bool showTerminals = false)
{
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, showTerminals);
// 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);
PdfLinks::removeUnusedPdfxNamespace(output);
out << "Exported " << diagrams.size() << " page(s) -> " << output << "\n";
return 0;
}
int exportImages(QETProject &project, const QString &format,
const QString &out_dir, bool showTerminals = false)
{
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()),
showTerminals);
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()),
showTerminals);
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;
}
/// One DXF file per diagram, written by the same code as the export dialog
/// (DxfExport) with the dialog's default options -- the export settings of
/// the preferences -- so both give the same file.
int exportDxf(QETProject &project, const QString &out_dir, bool showTerminals)
{
const QList<Diagram *> diagrams = project.diagrams();
if (diagrams.isEmpty()) {
err << "No diagrams to export.\n";
return 1;
}
QDir().mkpath(out_dir);
ExportProperties properties = ExportProperties::defaultExportProperties();
properties.format = QStringLiteral("DXF");
if (showTerminals)
properties.draw_terminals = true;
int index = 0;
bool has_images = false;
for (Diagram *diagram : diagrams) {
++index;
const QString path = QDir(out_dir).filePath(
diagramStem(diagram, index) + ".dxf");
// Createdxf answers a file it cannot open with a message box and
// exit(0), which headless means no message and a success code.
QFile probe(path);
if (!probe.open(QIODevice::WriteOnly)) {
err << "Cannot open '" << path << "' for writing.\n";
return 1;
}
probe.close();
const QSize size = DxfExport::folioSize(diagram, properties);
DxfExport::write(diagram, size.width(), size.height(), path, properties);
for (QGraphicsItem *item : diagram->items()) {
if (qgraphicsitem_cast<DiagramImageItem *>(item)) {
has_images = true;
break;
}
}
out << " " << path << "\n";
}
if (has_images)
err << "Note: DXF has no pictures in this format; "
"each picture is exported as an outline box.\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;
}
/// Bill of materials from the same project database and default query as the
/// GUI nomenclature export.
int exportBom(QETProject &project, const QString &output)
{
project.dataBase()->updateDB();
QSqlQuery query = project.dataBase()->newQuery(BomExport::defaultQuery());
if (!query.exec()) {
err << "BOM query failed: " << query.lastError().text() << "\n";
return 1;
}
int rows = 0;
const auto csv = BomExport::toCsv(
query, BomExport::defaultColumns(), true, &rows);
QString error;
if (!BomExport::writeCsv(output, csv, &error)) {
err << "Cannot write '" << output << "': " << error << "\n";
return 1;
}
out << "Exported " << rows << " component(s) -> " << output << "\n";
return 0;
}
QString csvField(const QString &value)
{
if (value.contains(QLatin1Char(';')) || value.contains(QLatin1Char('"'))
|| value.contains(QLatin1Char('\n')) || value.contains(QLatin1Char('\r')))
{
QString escaped = value;
escaped.replace(QLatin1Char('"'), QStringLiteral("\"\""));
return QLatin1Char('"') % escaped % QLatin1Char('"');
}
return value;
}
/// 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 QDomNodeList terminal_nodes = root.elementsByTagName("terminal");
const int terminals = terminal_nodes.count();
QStringList terminal_names;
for (int i = 0; i < terminals; ++i)
terminal_names << terminal_nodes.at(i).toElement().attribute("name");
// Two terminals with one name cannot be told apart in a wiring list
// (IEC 61666), the same rule the element editor applies on save.
const auto repeated = TerminalNameCheck::repeatedNames(terminal_names);
if (!repeated.isEmpty()) {
out << "FAIL " << path << " (repeated terminal names: "
<< TerminalNameCheck::describe(repeated) << ")\n";
return 2;
}
// QET loads the element but leaves out a shape with a "nan" or "inf"
// coordinate, on the folio and in the element editor.
const QDomNodeList description = root.elementsByTagName("description");
for (QDomNode n = description.isEmpty() ? QDomNode()
: description.at(0).firstChild() ;
!n.isNull() ; n = n.nextSibling()) {
const QDomElement shape = n.toElement();
if (!shape.isNull() && QET::hasNonFiniteGeometry(shape)) {
out << "WARN " << path << " (<" << shape.tagName()
<< "> with a non-finite coordinate is not drawn)\n";
return 1;
}
}
// 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;
}
const QString type = root.attribute("link_type");
const int unnamed = TerminalNameCheck::unnamedCount(terminal_names);
if (unnamed && !type.endsWith("_report")
&& type != "conductor_definition" && type != "thumbnail") {
out << "WARN " << path << " (" << unnamed << " of " << terminals
<< " terminals have no name)\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.
///
/// Most symbols leave their terminals unnamed, so each end also carries the
/// terminal's index -- the one the scripting API's addConductor() takes,
/// empty for two terminals at one point -- and its uuid, the one the project
/// file names the terminal by. These come last, after the columns the list
/// has always had, so a reader of those is not disturbed.
///
/// 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",
"from_terminal_index", "from_terminal_uuid", "to_terminal_index", "to_terminal_uuid"
};
QSqlQuery query = project.dataBase()->newQuery(
"SELECT " % columns.join(", ") %
" FROM wiring_list_view"
//Wire numbers are text, so a plain sort puts "10" before "9".
//Numeric ones first, ordered by value; anything non-numeric after,
//ordered as text. The trailing wire_number keeps ties stable.
" ORDER BY diagram_position,"
" CASE WHEN wire_number GLOB '[0-9]*' THEN 0 ELSE 1 END,"
" CAST(wire_number AS INTEGER),"
" 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;
}
//Written through QSaveFile so a failure part-way leaves the previous
//file intact rather than a truncated one, and with a UTF-8 byte order
//mark: without it Excel opens a .csv as the local 8-bit codepage and
//mangles any accented element label. Qt writes UTF-8 by default, so
//the bytes were already right -- the mark is what tells Excel so.
QSaveFile file(output);
if (!file.open(QIODevice::WriteOnly)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
static const char utf8_bom[] = "\xEF\xBB\xBF";
file.write(utf8_bom, 3);
file.write(csv.toUtf8());
if (!file.commit()) {
err << "Cannot write '" << output << "': " << file.errorString() << "\n";
return 1;
}
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)
{
// --show-terminals is a standalone switch (not tied to a position),
// so pull it out before the positional project/output arguments are
// collected below.
QStringList filtered = args;
const bool showTerminals = filtered.removeAll("--show-terminals") > 0;
QString flag;
QStringList rest;
for (int i = 0; i < filtered.size(); ++i) {
if (exportFlags().contains(filtered.at(i))) {
flag = filtered.at(i);
for (int j = i + 1; j < filtered.size(); ++j)
rest << filtered.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, showTerminals);
if (format == "dxf")
return exportDxf(project, output, showTerminals);
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, showTerminals);
}
} // namespace CLIExport