mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-01 07:14:14 +02:00
8b54ea400b
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.
845 lines
23 KiB
C++
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);
|
|
}
|