Files
qelectrotech-source-mirror/sources/materiallist/materiallist.cpp
T
Kellermorph 8b54ea400b Fix material list reading, selection and article fields
Follow-up to #1088, covering its review comments and one behaviour
change found while testing the feature:

- MaterialList::load() names the columns from the machine key line when
  the file has one, instead of from the translated label line above it.
  A catalogue written in another language fills the element fields
  again, and saving no longer replaces the key line with labels.
- The entry picked after "New entry" is found by comparing the columns
  one by one instead of comparing the two maps as a whole: the entry
  form leaves the empty columns out, so the record never matched the
  line that had just been written and the search was cleared for
  nothing. The search is now only given up when it really hides the new
  line.
- Applying a catalogue entry pushes an undo command only when the live
  edit is on. In the properties window, where there is none, the fields
  wait for "Apply", so "Cancel" gives the element its own values back
  instead of leaving the picked part in place.
- Cells a file holds past the header are kept in MaterialRecord::extra
  and written back, so appending an article never shortens a line.
- An empty cell of a column describing the article itself (MaterialList::
  isArticleBound: description, designation, manufacturer, order number,
  supplier, model, ratings, dimensions, auxiliary block) clears the
  field, so an element never keeps the manufacturer of the part picked
  before. An empty cell of any other column (function, comment, notes,
  plant, location, quantity, unity), and any column the file does not
  hold at all, leaves the field alone.
- Comments left where the review asked for them: the corner button
  lookup, the ';' separator fallback, the search filter cost.
2026-09-28 22:38:41 +02:00

845 lines
23 KiB
C++

/*
Copyright 2006-2026 The QElectroTech Team
This file is part of QElectroTech.
QElectroTech is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
(at your option) any later version.
QElectroTech is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "materiallist.h"
#include "../qetinformation.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
#include <QSettings>
#include <QStandardPaths>
#include <utility>
namespace {
/**
@brief Decode a csv file content.
The material file is written in utf-8 with a BOM, the same way the bill
of material export does, so spreadsheets open it with the right
encoding. A file written by hand in the local encoding is still
decodable: fromUtf8() is only used when the bytes are valid utf-8.
@param raw
@return
*/
QString decode(const QByteArray &raw)
{
if (raw.startsWith("\xEF\xBB\xBF")) {
return QString::fromUtf8(raw.constData() + 3, raw.size() - 3);
}
const QByteArray utf8 = QString::fromUtf8(raw).toUtf8();
if (utf8 == raw) {
return QString::fromUtf8(raw);
}
return QString::fromLocal8Bit(raw);
}
/**
@brief Tell whether a file exists and holds something.
An existing but empty file is the same as no file at all: it can be
filled with the template without losing anything.
@param path
@return
*/
bool hasContent(const QString &path)
{
const QFileInfo info(path);
return info.exists() && info.isFile() && info.size() > 0;
}
} // namespace
/**
@brief MaterialList::settingsKey
@return the QSettings key holding the path of the material file
*/
QString MaterialList::settingsKey()
{
return QStringLiteral("elements-collections/material-list-path");
}
/**
@brief MaterialList::configuredPath
@return the path of the material file as chosen in the preferences,
or a null QString when the user never set one.
*/
QString MaterialList::configuredPath()
{
QSettings settings;
const QString path = settings.value(settingsKey(), QStringLiteral("default")).toString();
if (path.isEmpty() || path == QLatin1String("default")) {
return QString();
}
return path;
}
/**
@brief MaterialList::setConfiguredPath
Store the path of the material file in the preferences.
@param path
*/
void MaterialList::setConfiguredPath(const QString &path)
{
QSettings settings;
settings.setValue(settingsKey(), path.isEmpty() ? QStringLiteral("default") : path);
}
/**
@brief MaterialList::defaultFileName
@return the file name used when the material file is created without an
explicit location.
*/
QString MaterialList::defaultFileName()
{
return QStringLiteral("qet_material_list.csv");
}
/**
@brief MaterialList::defaultPath
@return where the material file is created when no path is configured
yet : the standard documents folder.
*/
QString MaterialList::defaultPath()
{
QString dir = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
while (dir.endsWith(QLatin1Char('/'))) {
dir.chop(1);
}
if (dir.isEmpty()) {
dir = QDir::currentPath();
}
return dir + QLatin1Char('/') + defaultFileName();
}
/**
@brief MaterialList::savedHeaderState
@return the saved column layout of the selection window, empty when the
window was never opened or the layout could not be read back.
*/
QByteArray MaterialList::savedHeaderState()
{
QSettings settings;
return settings.value(QStringLiteral("elements-collections/material-list-header")).toByteArray();
}
/**
@brief MaterialList::saveHeaderState
Remember the column layout of the selection window.
@param state the header state as saved by QHeaderView::saveState()
*/
void MaterialList::saveHeaderState(const QByteArray &state)
{
QSettings settings;
settings.setValue(QStringLiteral("elements-collections/material-list-header"), state);
}
/**
@brief MaterialList::savedDialogSize
@return the size the selection window had the last time it was closed,
an invalid size when it was never opened.
*/
QSize MaterialList::savedDialogSize()
{
QSettings settings;
const QSize size(settings.value(QStringLiteral("elements-collections/material-list-width")).toInt(),
settings.value(QStringLiteral("elements-collections/material-list-height")).toInt());
//A size which was never written reads back as 0x0, which Qt still
//counts as valid : it must not be handed to resize().
return size.isEmpty() ? QSize() : size;
}
/**
@brief MaterialList::saveDialogSize
Remember the size of the selection window.
@param size
*/
void MaterialList::saveDialogSize(const QSize &size)
{
if (size.isEmpty()) {
return;
}
QSettings settings;
settings.setValue(QStringLiteral("elements-collections/material-list-width"), size.width());
settings.setValue(QStringLiteral("elements-collections/material-list-height"), size.height());
}
/**
@brief MaterialList::defaultColumns
@return the columns of a brand new material file, in writing order.
It is every information of the main article (everything but label and
formula, which QElectroTech computes itself) plus the one column that
describes an auxiliary block.
*/
QStringList MaterialList::defaultColumns()
{
QStringList columns = columnsForBlock(0);
columns << QStringLiteral("auxiliary");
return columns;
}
/**
@brief MaterialList::columnsForBlock
Columns of the material file that can be transferred to a block of the
element informations.
@param block 0 for the main article, 1 to 4 for an auxiliary article
@return
*/
QStringList MaterialList::columnsForBlock(int block)
{
static const QStringList main_columns = {
QStringLiteral("description"),
QStringLiteral("designation"),
QStringLiteral("manufacturer"),
QStringLiteral("manufacturer_reference"),
QStringLiteral("machine_manufacturer_reference"),
QStringLiteral("supplier"),
QStringLiteral("quantity"),
QStringLiteral("unity"),
QStringLiteral("function"),
QStringLiteral("comment"),
QStringLiteral("notes"),
QStringLiteral("model"),
QStringLiteral("category"),
QStringLiteral("voltage_rating"),
QStringLiteral("current_rating"),
QStringLiteral("plant"),
QStringLiteral("location"),
QStringLiteral("width"),
QStringLiteral("height"),
QStringLiteral("depth")
};
static const QStringList auxiliary_columns = {
QStringLiteral("description"),
QStringLiteral("designation"),
QStringLiteral("manufacturer"),
QStringLiteral("manufacturer_reference"),
QStringLiteral("machine_manufacturer_reference"),
QStringLiteral("supplier"),
QStringLiteral("quantity"),
QStringLiteral("unity"),
QStringLiteral("auxiliary")
};
if (block <= 0) {
return main_columns;
}
if (block > 4) {
return QStringList();
}
return auxiliary_columns;
}
/**
@brief MaterialList::elementInfoKey
Tell which element information a column of the material file feeds
when it is applied to a block.
@param column a column of the material file
@param block 0 for the main article, 1 to 4 for an auxiliary article
@return the element information key, or a null QString when that column
does not belong to that block
*/
QString MaterialList::elementInfoKey(const QString &column, int block)
{
if (!columnsForBlock(block).contains(column)) {
return QString();
}
if (block <= 0) {
return column;
}
//Every auxiliary column carries the same name as its main counterpart,
//only the suffix tells the block apart. The block description column
//is the exception: it is named auxiliary in the file and auxiliary1 to
//auxiliary4 in the element informations.
if (column == QLatin1String("auxiliary")) {
return QStringLiteral("auxiliary%1").arg(block);
}
return column + QStringLiteral("_auxiliary%1").arg(block);
}
/**
@brief MaterialList::isArticleBound
Tell whether a column describes the article itself, rather than what
the element does or where it stands.
An empty cell of such a column clears the field it feeds when the
entry is applied: an element cannot carry the manufacturer of two
parts at once, so the value of the article picked before has to go.
Every other column (function, comment, notes, plant, location, and
quantity and unity, which say how many pieces this element takes and
in which unit) is left alone by an empty cell, and so is a column the
file does not hold at all: a file which mentions nothing about a
field is no reason to empty it.
@param column a column of the material file
@return true when an empty cell of that column clears the field
*/
bool MaterialList::isArticleBound(const QString &column)
{
static const QStringList article_columns = {
QStringLiteral("description"),
QStringLiteral("designation"),
QStringLiteral("manufacturer"),
QStringLiteral("manufacturer_reference"),
QStringLiteral("machine_manufacturer_reference"),
QStringLiteral("supplier"),
QStringLiteral("model"),
QStringLiteral("category"),
QStringLiteral("voltage_rating"),
QStringLiteral("current_rating"),
QStringLiteral("width"),
QStringLiteral("height"),
QStringLiteral("depth"),
QStringLiteral("auxiliary")
};
return article_columns.contains(column);
}
/**
@brief MaterialList::translatedColumn
@param column a column of the material file
@return that column named the way it is shown to the user, in the
current language. A column QElectroTech doesn't know is returned as it
is, since it can only have been named by the user.
*/
QString MaterialList::translatedColumn(const QString &column)
{
if (column == QLatin1String("auxiliary")) {
return QCoreApplication::translate("MaterialList", "Bloc auxiliaire");
}
const QString translated = QETInformation::translatedInfoKey(column);
return translated.isEmpty() ? column : translated;
}
/**
@brief MaterialList::translatedHeader
The label line of a material file : the columns the way the user reads
them, in the current language.
@param columns
@return
*/
QStringList MaterialList::translatedHeader(const QStringList &columns)
{
QStringList header;
header.reserve(columns.size());
for (const QString &column : columns) {
header.append(translatedColumn(column));
}
return header;
}
/**
@brief MaterialList::headerAliasMap
@return every accepted header cell of a material file, lowercased,
mapped to the canonical column it means.
Beside the key itself, a header may use the translated name of the
information ("Artikelbeschreibung" for description), so a file written
by hand in the user's language is understood as well as the canonical
one.
*/
QMap<QString, QString> MaterialList::headerAliasMap()
{
QMap<QString, QString> map;
const QStringList keys = QETInformation::elementInfoKeys();
for (const QString &key : keys)
{
map.insert(key.toLower(), key);
const QString translated = QETInformation::translatedInfoKey(key);
if (!translated.isEmpty()) {
map.insert(translated.toLower(), key);
}
}
map.insert(QStringLiteral("auxiliary"), QStringLiteral("auxiliary"));
const QString auxiliary_label = QCoreApplication::translate("MaterialList", "Bloc auxiliaire").toLower();
map.insert(auxiliary_label, QStringLiteral("auxiliary"));
return map;
}
/**
@brief MaterialList::canonicalColumn
Turn one header cell into a canonical column name.
@param header_cell
@param already_used the names chosen for the previous cells, to keep
the header free of duplicates
@param alias_map lowercased alias -> canonical name
@return
*/
QString MaterialList::canonicalColumn(const QString &header_cell,
const QStringList &already_used,
const QMap<QString, QString> &alias_map)
{
const QString raw = header_cell.trimmed();
if (raw.isEmpty()) {
//A nameless column keeps its data and its place, it just can't be
//matched to an element information.
QString name = QStringLiteral("column_%1").arg(already_used.size() + 1);
int i = already_used.size() + 1;
while (already_used.contains(name)) {
name = QStringLiteral("column_%1").arg(++i);
}
return name;
}
const auto it = alias_map.constFind(raw.toLower());
if (it != alias_map.constEnd() && !already_used.contains(it.value())) {
return it.value();
}
//Either an unknown name, or a second cell meaning something which is
//already used : the cell is kept as it is written, its data is never
//thrown away.
QString name = raw;
int i = 2;
while (already_used.contains(name)) {
name = raw + QStringLiteral("_%1").arg(i++);
}
return name;
}
/**
@brief MaterialList::isMachineHeaderLine
Tell whether the second line of a material file is the machine header
QElectroTech writes under the label line : the canonical column names,
kept so that a file written in one language is still understood in
another one.
The check is deliberately strict about what it recognises and lenient
about the rest : every cell is either a known column name, or the very
same text as the label above it (which is how an unknown column shows
up in both lines). A data row holding free text never passes.
@param label_line the first line of the file, the one the user reads
@param candidate the line which may be the machine header
@param alias_map lowercased alias -> canonical name
@return true when candidate must not be read as a record
*/
bool MaterialList::isMachineHeaderLine(const QStringList &label_line,
const QStringList &candidate,
const QMap<QString, QString> &alias_map)
{
if (candidate.isEmpty() || candidate.size() != label_line.size()) {
return false;
}
bool has_known_column = false;
for (int i = 0; i < candidate.size(); ++i)
{
const QString cell = candidate.at(i).trimmed();
if (cell.isEmpty()) {
continue;
}
if (alias_map.contains(cell.toLower()))
{
has_known_column = true;
continue;
}
if (cell == label_line.at(i).trimmed()) {
continue;
}
return false;
}
return has_known_column;
}
/**
@brief MaterialList::detectSeparator
Tell which separator a csv text uses. Spreadsheets of the german
region write a semicolon, the english ones a comma, some tools a tab.
@param first_line the header line
@return
*/
QChar MaterialList::detectSeparator(const QString &first_line)
{
const int semicolons = first_line.count(QLatin1Char(';'));
const int tabs = first_line.count(QLatin1Char('\t'));
const int commas = first_line.count(QLatin1Char(','));
if (semicolons == 0 && tabs == 0 && commas == 0) {
//A header holding no separator character at all is a file of a
//single column : falling back on the semicolon is deliberate,
//it is the one QElectroTech writes itself, and such a file
//needs no detection anyway. Leave this alone.
return QLatin1Char(';');
}
if (tabs >= semicolons && tabs >= commas && tabs > 0) {
return QLatin1Char('\t');
}
if (commas > semicolons) {
return QLatin1Char(',');
}
return QLatin1Char(';');
}
/**
@brief MaterialList::parseCsv
Split a csv text into rows. Quoted fields may contain the separator
and line breaks, doubled quotes mean one quote.
@param content
@param separator
@return
*/
QList<QStringList> MaterialList::parseCsv(const QString &content, QChar separator)
{
QList<QStringList> rows;
QStringList row;
QString field;
bool quoted = false;
for (int i = 0; i < content.size(); ++i)
{
const QChar c = content.at(i);
if (quoted)
{
if (c == QLatin1Char('"'))
{
if (i + 1 < content.size() && content.at(i + 1) == QLatin1Char('"')) {
field += QLatin1Char('"');
++i;
} else {
quoted = false;
}
} else {
field += c;
}
}
else if (c == QLatin1Char('"') && field.isEmpty())
{
quoted = true;
}
else if (c == separator)
{
row.append(field);
field.clear();
}
else if (c == QLatin1Char('\n'))
{
row.append(field);
field.clear();
rows.append(row);
row.clear();
}
else if (c != QLatin1Char('\r'))
{
field += c;
}
}
if (!field.isEmpty() || !row.isEmpty()) {
row.append(field);
rows.append(row);
}
//A trailing line break produces a last, empty row : without this it
//would show up as a blank line in the selection dialog.
while (!rows.isEmpty() && rows.last().size() == 1 && rows.last().first().isEmpty()) {
rows.removeLast();
}
return rows;
}
/**
@brief MaterialList::serializeCsv
Build the content of a csv file : utf-8 BOM, quoted fields, one line
per record. Same dialect as the bill of material export.
The file opens with two header lines : the columns the way the user
reads them, then the canonical names QElectroTech matches against the
element informations.
@param header_lines the header lines, in writing order
@param rows
@param separator
@return
*/
QByteArray MaterialList::serializeCsv(const QList<QStringList> &header_lines, const QList<QStringList> &rows, QChar separator)
{
//Helpers duplicated from the bill of material export : every field is
//quoted, so a semicolon inside a value is never read as a separator.
auto escape = [](QString value) {
value.replace(QLatin1Char('"'), QStringLiteral("\"\""));
return QLatin1Char('"') + value + QLatin1Char('"');
};
auto line = [&](const QStringList &values) {
QStringList escaped;
escaped.reserve(values.size());
for (const QString &value : values) {
escaped.append(escape(value));
}
return escaped.join(separator) + QLatin1Char('\n');
};
QByteArray out("\xEF\xBB\xBF");
for (const QStringList &header_line : header_lines) {
out += line(header_line).toUtf8();
}
for (const QStringList &row : rows) {
out += line(row).toUtf8();
}
return out;
}
/**
@brief MaterialList::load
Read a material file.
@param path
@param data receives the columns and the records
@param error receives a message when the file can't be read
@return true on success
*/
bool MaterialList::load(const QString &path, MaterialListData *data, QString *error)
{
if (error) {
error->clear();
}
if (!data) {
if (error) {
*error = QCoreApplication::translate("MaterialList", "Aucun récepteur pour le répertoire de matériaux.");
}
return false;
}
data->columns.clear();
data->records.clear();
data->separator = QLatin1Char(';');
QFile file(path);
if (!file.open(QIODevice::ReadOnly))
{
if (error) {
*error = file.errorString();
}
return false;
}
const QByteArray raw = file.readAll();
file.close();
const QString content = decode(raw);
if (content.trimmed().isEmpty()) {
return true;
}
const int eol = content.indexOf(QLatin1Char('\n'));
data->separator = detectSeparator(eol < 0 ? content : content.left(eol));
QList<QStringList> rows = parseCsv(content, data->separator);
if (rows.isEmpty()) {
return true;
}
const QStringList header = rows.takeFirst();
const QMap<QString, QString> alias_map = headerAliasMap();
//The label line is followed by the machine header QElectroTech
//writes itself : the canonical names, so that the file is read the
//same way whatever the language it was written in. That machine
//line is the one naming the columns when it is there : the label
//line above it may be written in another language than the one
//running now, and would then match nothing. The label line stays
//on disk, it is only what the user reads.
QStringList machine_line;
if (!rows.isEmpty() && isMachineHeaderLine(header, rows.first(), alias_map)) {
machine_line = rows.takeFirst();
}
QStringList used;
for (int i = 0; i < header.size(); ++i)
{
//An empty machine cell means the column is named by the label
//above it (a column added by hand shows up in both lines).
QString cell = header.at(i);
if (i < machine_line.size() && !machine_line.at(i).isEmpty()) {
cell = machine_line.at(i);
}
const QString column = canonicalColumn(cell, used, alias_map);
used.append(column);
data->columns.append(column);
}
for (const QStringList &row : std::as_const(rows))
{
MaterialRecord record;
bool empty = true;
for (int i = 0; i < data->columns.size(); ++i)
{
const QString value = i < row.size() ? row.at(i) : QString();
if (!value.isEmpty()) {
empty = false;
}
record.setValue(data->columns.at(i), value);
}
//Cells written past the header are not thrown away : they are
//written back the way they were read, and they are content too,
//so a line only they fill is kept as well.
for (int i = data->columns.size(); i < row.size(); ++i)
{
if (!row.at(i).isEmpty()) {
empty = false;
}
record.extra.append(row.at(i));
}
if (!empty) {
data->records.append(record);
}
}
return true;
}
/**
@brief MaterialList::writeFile
Write a whole material file atomically : a crash or a full disk never
leaves a half written catalogue behind.
@param path
@param data
@param error receives a message when the file can't be written
@return true on success
*/
bool MaterialList::writeFile(const QString &path, const MaterialListData &data, QString *error)
{
if (error) {
error->clear();
}
QList<QStringList> rows;
rows.reserve(data.records.size());
for (const MaterialRecord &record : data.records)
{
QStringList row;
row.reserve(data.columns.size());
for (const QString &column : data.columns) {
row.append(record.value(column));
}
//The cells the file holds past the header, if any, follow the
//columns : a line wider than the header stays that wide.
row.append(record.extra);
rows.append(row);
}
QList<QStringList> header_lines;
header_lines.append(translatedHeader(data.columns));
header_lines.append(data.columns);
const QByteArray content = serializeCsv(header_lines, rows, data.separator);
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(content) != content.size())
{
if (error) {
*error = file.errorString();
}
file.cancelWriting();
return false;
}
if (!file.commit())
{
if (error) {
*error = file.errorString();
}
return false;
}
return true;
}
/**
@brief MaterialList::appendRecord
Add one article at the end of a material file.
The file is read again first: changes the user just saved from his
spreadsheet are part of what gets rewritten, never dropped.
@param path
@param record
@param error receives a message when the file can't be written
@return true on success
*/
bool MaterialList::appendRecord(const QString &path, const MaterialRecord &record, QString *error)
{
if (error) {
error->clear();
}
MaterialListData data;
if (hasContent(path))
{
if (!load(path, &data, error)) {
return false;
}
if (data.columns.isEmpty())
{
if (error) {
*error = QCoreApplication::translate("MaterialList", "Le fichier ne contient pas d'en-tête : colonnes manquantes.");
}
return false;
}
}
else
{
data.columns = defaultColumns();
data.separator = QLatin1Char(';');
}
data.records.append(record);
return writeFile(path, data, error);
}
/**
@brief MaterialList::createFile
Create an empty material file holding only the header line.
@param path
@param error receives a message when the file can't be written
@return true on success
*/
bool MaterialList::createFile(const QString &path, QString *error)
{
if (error) {
error->clear();
}
if (hasContent(path))
{
if (error) {
*error = QCoreApplication::translate("MaterialList", "Le fichier existe déjà et n'est pas vide.");
}
return false;
}
MaterialListData data;
data.columns = defaultColumns();
data.separator = QLatin1Char(';');
return writeFile(path, data, error);
}
/**
@brief MaterialList::isEmptyFile
@param path
@return true when no file, or an empty one, exists at path
*/
bool MaterialList::isEmptyFile(const QString &path)
{
return !hasContent(path);
}