diff --git a/CMakeLists.txt b/CMakeLists.txt index 6af925c12..1694a98c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -332,6 +332,7 @@ target_include_directories( ${QET_DIR}/sources/factory/ui ${QET_DIR}/sources/print ${QET_DIR}/sources/svg + ${QET_DIR}/sources/materiallist ) if(NOT BUILD_WITH_KF) diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 9f92fcc3e..f46101bfa 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -73,6 +73,7 @@ set(QET_RES_FILES ${QET_DIR}/sources/editor/ui/terminaleditor.ui ${QET_DIR}/sources/ElementsCollection/ui/renamedialog.ui ${QET_DIR}/sources/factory/ui/addtabledialog.ui + ${QET_DIR}/sources/materiallist/materialselectiondialog.ui ${QET_DIR}/sources/NameList/ui/namelistdialog.ui ${QET_DIR}/sources/NameList/ui/namelistwidget.ui ${QET_DIR}/sources/print/projectprintwindow.ui @@ -863,6 +864,13 @@ set(QET_SRC_FILES ${QET_DIR}/sources/utils/qetutils.cpp ${QET_DIR}/sources/utils/qetutils.h + ${QET_DIR}/sources/materiallist/materialentrydialog.cpp + ${QET_DIR}/sources/materiallist/materialentrydialog.h + ${QET_DIR}/sources/materiallist/materiallist.cpp + ${QET_DIR}/sources/materiallist/materiallist.h + ${QET_DIR}/sources/materiallist/materialselectiondialog.cpp + ${QET_DIR}/sources/materiallist/materialselectiondialog.h + ${QET_DIR}/sources/xml/terminalstripitemxml.cpp ${QET_DIR}/sources/xml/terminalstripitemxml.h ${QET_DIR}/sources/xml/terminalstriplayoutpatternxml.cpp diff --git a/sources/materiallist/materialentrydialog.cpp b/sources/materiallist/materialentrydialog.cpp new file mode 100644 index 000000000..2795df12f --- /dev/null +++ b/sources/materiallist/materialentrydialog.cpp @@ -0,0 +1,144 @@ +/* + 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 . +*/ +#include "materialentrydialog.h" + +#include "../qetmessagebox.h" + +#include +#include +#include +#include +#include +#include +#include + +/** + @brief MaterialEntryDialog::MaterialEntryDialog + Build one field per column of the material file. + @param columns the columns of the file, in file order + @param parent parent widget +*/ +MaterialEntryDialog::MaterialEntryDialog(const QStringList &columns, QWidget *parent) : + QDialog(parent), + m_columns(columns) +{ + setWindowTitle(tr("Nouvelle entrée")); + + //Two fields per row : the form shows the whole article at once + //instead of stacking twenty lines in a narrow column. + setMinimumSize(780, 560); + resize(1120, 800); + + auto *main_layout = new QVBoxLayout(this); + + auto *intro = new QLabel( + tr("Renseignez l'article à ajouter. Les champs laissés vides " + "restent vides dans le fichier."), this); + intro->setWordWrap(true); + main_layout->addWidget(intro); + + auto *scroll_area = new QScrollArea(this); + scroll_area->setWidgetResizable(true); + scroll_area->setFrameShape(QFrame::NoFrame); + + auto *container = new QWidget(scroll_area); + auto *grid = new QGridLayout(container); + + for (int i = 0; i < m_columns.size(); ++i) + { + const QString column = m_columns.at(i); + + auto *label = new QLabel(MaterialList::translatedColumn(column) + QLatin1Char(' '), container); + label->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + auto *edit = new QLineEdit(container); + edit->setClearButtonEnabled(true); + + label->setBuddy(edit); + const int row = i / 2; + const int pair = i % 2; + grid->addWidget(label, row, pair * 2); + grid->addWidget(edit, row, pair * 2 + 1); + m_edits.append(edit); + } + grid->setColumnStretch(1, 1); + grid->setColumnStretch(3, 1); + + scroll_area->setWidget(container); + main_layout->addWidget(scroll_area, 1); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttons->button(QDialogButtonBox::Ok)->setText(tr("Enregistrer")); + buttons->button(QDialogButtonBox::Cancel)->setText(tr("Annuler")); + connect(buttons, &QDialogButtonBox::accepted, this, &MaterialEntryDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &MaterialEntryDialog::reject); + main_layout->addWidget(buttons); + + if (!m_edits.isEmpty()) { + m_edits.first()->setFocus(); + } +} + +/** + @brief MaterialEntryDialog::~MaterialEntryDialog + Destructor +*/ +MaterialEntryDialog::~MaterialEntryDialog() = default; + +/** + @brief MaterialEntryDialog::record + @return the article described by the form. Empty fields are left out : + an empty column never means anything for an article which is being + created. +*/ +MaterialRecord MaterialEntryDialog::record() const +{ + MaterialRecord record; + + for (int i = 0; i < m_columns.size() && i < m_edits.size(); ++i) + { + QString value = m_edits.at(i)->text(); + value.remove(QLatin1Char('\r')); + value.remove(QLatin1Char('\n')); + value = value.trimmed(); + if (!value.isEmpty()) { + record.setValue(m_columns.at(i), value); + } + } + + return record; +} + +/** + @brief MaterialEntryDialog::accept + Refuse an empty form: an article without any information is not worth + a line in the file. +*/ +void MaterialEntryDialog::accept() +{ + if (record().values.isEmpty()) + { + QET::QetMessageBox::warning(this, + tr("Aucun renseignement"), + tr("Saisissez au moins un renseignement " + "pour créer une entrée.")); + return; + } + + QDialog::accept(); +} diff --git a/sources/materiallist/materialentrydialog.h b/sources/materiallist/materialentrydialog.h new file mode 100644 index 000000000..0ab51f6ac --- /dev/null +++ b/sources/materiallist/materialentrydialog.h @@ -0,0 +1,54 @@ +/* + 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 . +*/ +#ifndef MATERIALENTRYDIALOG_H +#define MATERIALENTRYDIALOG_H + +#include "../materiallist/materiallist.h" + +#include +#include + +class QLineEdit; + +/** + @brief The form used to add an article to the material file. + + One field per column of the file, including the columns QElectroTech + does not know : whatever the user typed there is written back to the + file untouched when a later row is appended. +*/ +class MaterialEntryDialog : public QDialog +{ + Q_OBJECT + + public: + explicit MaterialEntryDialog(const QStringList &columns, QWidget *parent = nullptr); + ~MaterialEntryDialog() override; + + /** @brief the article described by the form */ + MaterialRecord record() const; + + void accept() override; + + //ATTRIBUTES + private: + QStringList m_columns; + QList m_edits; +}; + +#endif // MATERIALENTRYDIALOG_H diff --git a/sources/materiallist/materiallist.cpp b/sources/materiallist/materiallist.cpp new file mode 100644 index 000000000..e84b1ec1c --- /dev/null +++ b/sources/materiallist/materiallist.cpp @@ -0,0 +1,778 @@ +/* + 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 . +*/ +#include "materiallist.h" + +#include "../qetinformation.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +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::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 MaterialList::headerAliasMap() +{ + QMap 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 &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 &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) { + 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 MaterialList::parseCsv(const QString &content, QChar separator) +{ + QList 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 &header_lines, const QList &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 rows = parseCsv(content, data->separator); + if (rows.isEmpty()) { + return true; + } + + const QStringList header = rows.takeFirst(); + const QMap 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. + if (!rows.isEmpty() && isMachineHeaderLine(header, rows.first(), alias_map)) { + rows.takeFirst(); + } + + QStringList used; + for (const QString &cell : header) + { + 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); + } + 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 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)); + } + rows.append(row); + } + + QList 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); +} diff --git a/sources/materiallist/materiallist.h b/sources/materiallist/materiallist.h new file mode 100644 index 000000000..66b5cda77 --- /dev/null +++ b/sources/materiallist/materiallist.h @@ -0,0 +1,121 @@ +/* + 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 . +*/ +#ifndef MATERIALLIST_H +#define MATERIALLIST_H + +#include +#include +#include +#include +#include +#include +#include + +/** + @brief One line of the material file. + + Keys are column names : either a canonical element information key + (description, manufacturer...) or the raw name of a column added by + the user, which QElectroTech does not know about and therefore never + Transfers to an element, but keeps intact when a row is appended. +*/ +struct MaterialRecord +{ + QMap values; + + QString value(const QString &column) const {return values.value(column);} + void setValue(const QString &column, const QString &value) {values.insert(column, value);} + bool operator==(const MaterialRecord &other) const {return values == other.values;} +}; + +/** + @brief The whole content of a material file. + + Both the column names and the separator are read from the file rather + than imposed, so a file edited in a spreadsheet keeps working : the + separator is detected (;, tab or ,), unknown columns are preserved, and + only the header cells that can be matched to a known element + information are canonicalized. +*/ +struct MaterialListData +{ + QStringList columns; + QList records; + QChar separator = QLatin1Char(';'); + + bool isEmpty() const {return columns.isEmpty() && records.isEmpty();} +}; + +/** + @brief The MaterialList class reads and writes the material file (a CSV + file listing purchasable articles) and maps its columns onto element + informations. + + This is a pure data class : it does not know anything about widgets, + so it stays usable from the tests and from the command line. +*/ +class MaterialList +{ + public: + //Settings + static QString settingsKey(); + static QString configuredPath(); + static void setConfiguredPath(const QString &path); + static QString defaultPath(); + static QString defaultFileName(); + + //Geometry of the selection window, remembered from one opening + //to the next : column widths are part of how the user reads his + //own catalogue and must not be lost when the window closes. + static QByteArray savedHeaderState(); + static void saveHeaderState(const QByteArray &state); + static QSize savedDialogSize(); + static void saveDialogSize(const QSize &size); + + //Columns + static QStringList defaultColumns(); + static QStringList columnsForBlock(int block); + static QString elementInfoKey(const QString &column, int block); + static QString translatedColumn(const QString &column); + static QStringList translatedHeader(const QStringList &columns); + + //File io + static bool load(const QString &path, MaterialListData *data, QString *error = nullptr); + static bool writeFile(const QString &path, const MaterialListData &data, QString *error = nullptr); + static bool appendRecord(const QString &path, const MaterialRecord &record, QString *error = nullptr); + static bool createFile(const QString &path, QString *error = nullptr); + static bool isEmptyFile(const QString &path); + + //Csv helpers, public only to keep them testable + static QList parseCsv(const QString &content, QChar separator); + static QByteArray serializeCsv(const QList &header_lines, + const QList &rows, + QChar separator); + static QChar detectSeparator(const QString &first_line); + + private: + static QString canonicalColumn(const QString &header_cell, + const QStringList &already_used, + const QMap &alias_map); + static QMap headerAliasMap(); + static bool isMachineHeaderLine(const QStringList &label_line, + const QStringList &candidate, + const QMap &alias_map); +}; + +#endif // MATERIALLIST_H diff --git a/sources/materiallist/materialselectiondialog.cpp b/sources/materiallist/materialselectiondialog.cpp new file mode 100644 index 000000000..d7265de7e --- /dev/null +++ b/sources/materiallist/materialselectiondialog.cpp @@ -0,0 +1,610 @@ +/* + 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 . +*/ +#include "materialselectiondialog.h" + +#include "materialentrydialog.h" +#include "../qetmessagebox.h" +#include "ui_materialselectiondialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + @brief MaterialTableModel::MaterialTableModel + Constructor + @param parent parent object +*/ +MaterialTableModel::MaterialTableModel(QObject *parent) : + QAbstractTableModel(parent) +{ +} + +/** + @brief MaterialTableModel::setMaterial + Replace the displayed file content + @param columns + @param records +*/ +void MaterialTableModel::setMaterial(const QStringList &columns, const QList &records) +{ + beginResetModel(); + m_columns = columns; + m_records = records; + endResetModel(); +} + +/** + @brief MaterialTableModel::record + @param row + @return the record of that row, a null one when out of range +*/ +MaterialRecord MaterialTableModel::record(int row) const +{ + return m_records.value(row); +} + +/** + @brief MaterialTableModel::rowCount + @param parent + @return +*/ +int MaterialTableModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_records.size(); +} + +/** + @brief MaterialTableModel::columnCount + @param parent + @return +*/ +int MaterialTableModel::columnCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : m_columns.size(); +} + +/** + @brief MaterialTableModel::data + @param index + @param role + @return +*/ +QVariant MaterialTableModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() + || index.row() < 0 || index.row() >= m_records.size() + || index.column() < 0 || index.column() >= m_columns.size()) + { + return QVariant(); + } + + const QString value = m_records.at(index.row()).value(m_columns.at(index.column())); + + switch (role) + { + case Qt::DisplayRole: + case Qt::ToolTipRole: + //The tooltip shows the whole cell once the column is too narrow + return value; + default: + return QVariant(); + } +} + +/** + @brief MaterialTableModel::headerData + @param section + @param orientation + @param role + @return +*/ +QVariant MaterialTableModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + if (role != Qt::DisplayRole) { + return QVariant(); + } + + if (orientation == Qt::Horizontal) + { + if (section >= 0 && section < m_columns.size()) { + return MaterialList::translatedColumn(m_columns.at(section)); + } + return QVariant(); + } + + return section + 1; +} + +/** + @brief MaterialFilterProxy::MaterialFilterProxy + Constructor + @param model the material table model, also used as source model + @param parent parent object +*/ +MaterialFilterProxy::MaterialFilterProxy(MaterialTableModel *model, QObject *parent) : + QSortFilterProxyModel(parent), + m_model(model) +{ + setSourceModel(model); + setFilterCaseSensitivity(Qt::CaseInsensitive); + setDynamicSortFilter(true); +} + +/** + @brief MaterialFilterProxy::setTokens + Set the words every row must hold to be shown. + @param tokens +*/ +void MaterialFilterProxy::setTokens(const QStringList &tokens) +{ + if (m_tokens == tokens) { + return; + } + m_tokens = tokens; + //The plain string filter of the base class is not what keeps the + //rows (filterAcceptsRow() below is), setting it is only how the + //base class is told to run the filter again. + setFilterFixedString(tokens.join(QLatin1Char(' '))); +} + +/** + @brief MaterialFilterProxy::filterAcceptsRow + A row is kept when each token is found in at least one of its cells : + the words may be spread over different columns, as a search for + "Hilfsschalter Schneider" expects. + @param source_row + @param source_parent + @return +*/ +bool MaterialFilterProxy::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + Q_UNUSED(source_parent) + + if (m_tokens.isEmpty() || !m_model) { + return true; + } + + const QStringList columns = m_model->columns(); + for (const QString &token : m_tokens) + { + bool found = false; + for (int column = 0; column < columns.size(); ++column) + { + const QModelIndex index = m_model->index(source_row, column); + const QString value = m_model->data(index, Qt::DisplayRole).toString(); + if (value.contains(token, Qt::CaseInsensitive)) + { + found = true; + break; + } + } + if (!found) { + return false; + } + } + + return true; +} + +/** + @brief MaterialSelectionDialog::MaterialSelectionDialog + Constructor + @param path path of the material file + @param parent parent widget +*/ +MaterialSelectionDialog::MaterialSelectionDialog(const QString &path, + QWidget *parent) : + QDialog(parent), + ui(new Ui::MaterialSelectionDialog), + m_path(path) +{ + ui->setupUi(this); + + m_model = new MaterialTableModel(this); + m_proxy = new MaterialFilterProxy(m_model, this); + + ui->m_table_view->setModel(m_proxy); + ui->m_table_view->setSortingEnabled(true); + ui->m_table_view->horizontalHeader()->setStretchLastSection(true); + + //The wheel is taken in hand here : it moves the rows, and the + //columns when the movement is horizontal or Shift is held. + ui->m_table_view->viewport()->installEventFilter(this); + + //The row numbers are the order of the file : clicking them, or the + //corner just above them, sorts the table by them, which means the + //table is no longer sorted at all. + for (QAbstractButton *button : ui->m_table_view->findChildren()) + { + if (button->inherits("QTableCornerButton")) { + connect(button, &QAbstractButton::clicked, + this, &MaterialSelectionDialog::resetSorting); + } + } + ui->m_table_view->verticalHeader()->setSectionsClickable(true); + connect(ui->m_table_view->verticalHeader(), &QHeaderView::sectionClicked, + this, &MaterialSelectionDialog::resetSorting); + + ui->m_button_box->button(QDialogButtonBox::Ok)->setText(tr("Appliquer")); + ui->m_button_box->button(QDialogButtonBox::Cancel)->setText(tr("Annuler")); + + connect(ui->m_search_edit, &QLineEdit::textChanged, + this, &MaterialSelectionDialog::searchChanged); + if (QItemSelectionModel *selection_model = ui->m_table_view->selectionModel()) + { + connect(selection_model, &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &, const QItemSelection &) { updateApplyButton(); }); + } + + reload(); + + if (const QSize saved_size = MaterialList::savedDialogSize(); saved_size.isValid()) { + resize(saved_size); + } + + const int description_column = m_data.columns.indexOf(QStringLiteral("description")); + if (description_column >= 0) { + ui->m_table_view->sortByColumn(description_column, Qt::AscendingOrder); + } + + //Last, so that the columns the user widened, moved or sorted last + //time are the ones he finds again. + restoreViewState(); + + updateApplyButton(); +} + +/** + @brief MaterialSelectionDialog::~MaterialSelectionDialog + Destructor +*/ +MaterialSelectionDialog::~MaterialSelectionDialog() +{ + delete ui; +} + +/** + @brief MaterialSelectionDialog::restoreViewState + Give the columns back the width, the place and the order they had the + last time the window was closed. +*/ +void MaterialSelectionDialog::restoreViewState() +{ + const QByteArray state = MaterialList::savedHeaderState(); + if (state.isEmpty()) { + return; + } + ui->m_table_view->horizontalHeader()->restoreState(state); + + //Every column of the file is shown, even one an older version of + //the window had hidden because no record used it yet. + for (int column = 0; column < ui->m_table_view->model()->columnCount(); ++column) { + ui->m_table_view->setColumnHidden(column, false); + } +} + +/** + @brief MaterialSelectionDialog::saveViewState + Remember how the columns were laid out, and how big the window was. +*/ +void MaterialSelectionDialog::saveViewState() +{ + MaterialList::saveHeaderState(ui->m_table_view->horizontalHeader()->saveState()); + MaterialList::saveDialogSize(size()); +} + +/** + @brief MaterialSelectionDialog::resetSorting + Give the table back the order of the file: no column sorted and no + arrow in the header, the rows counting from one down again. +*/ +void MaterialSelectionDialog::resetSorting() +{ + //Sorting by the row numbers is sorting by nothing : without a + //column to sort by, the proxy writes the rows the way the file + //does. + m_proxy->sort(-1, Qt::AscendingOrder); + ui->m_table_view->horizontalHeader()->setSortIndicator(-1, Qt::AscendingOrder); +} + +/** + @brief MaterialSelectionDialog::done + Every way out of the window goes through here, so a column resized by + the user is never lost, be it "Apply", "Cancel" or the window manager + close button. + @param result +*/ +void MaterialSelectionDialog::done(int result) +{ + saveViewState(); + QDialog::done(result); +} + +/** + @brief MaterialSelectionDialog::eventFilter + The table takes the wheel in hand: the rows must move up and down and + the columns left and right, whether the platform does anything with + wheel events or not. + @param watched + @param event + @return true when the event was handled here +*/ +bool MaterialSelectionDialog::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == ui->m_table_view->viewport() && event->type() == QEvent::Wheel) + { + scrollTable(static_cast(event)); + return true; + } + return QDialog::eventFilter(watched, event); +} + +/** + @brief wheelAmount + Turn one wheel movement into an amount to scroll. + + @param delta the movement, in eighths of a degree (120 per notch) or + in pixels, depending on the device + @param counting_items true when the scroll bar counts rows and columns + instead of pixels, which is how the scroll bars of a table work + @return how far the scroll bar moves, in its own unit +*/ +static int wheelAmount(int delta, bool counting_items) +{ + if (counting_items) { + return 3 * qRound(double(delta) / QWheelEvent::DefaultDeltasPerStep); + } + return delta; +} + +/** + @brief MaterialSelectionDialog::scrollTable + Scroll the table with the wheel, in both directions. + + A wheel which only turns moves the rows. A movement reported on the + horizontal axis (a wheel which tilts, a touchpad swiped sideways), or + Shift held down on a plain wheel, moves the columns instead, which is + how the rest of a wide catalogue is reached. + + Three rows or columns travel with every notch, the same amount Qt + itself scrolls when it gets the wheel. + @param event +*/ +void MaterialSelectionDialog::scrollTable(QWheelEvent *event) +{ + QPoint delta = event->angleDelta(); + if (delta.isNull()) { + delta = event->pixelDelta(); + } + + //Shift turns the wheel movement sideways. + if ((event->modifiers() & Qt::ShiftModifier) && delta.x() == 0 && delta.y() != 0) { + delta.setX(delta.y()); + delta.setY(0); + } + if (delta.isNull()) { + return; + } + + if (delta.y() != 0 && ui->m_table_view->verticalScrollBar()) + { + const bool counting_rows = + ui->m_table_view->verticalScrollMode() == QAbstractItemView::ScrollPerItem; + QScrollBar *bar = ui->m_table_view->verticalScrollBar(); + bar->setValue(bar->value() - wheelAmount(delta.y(), counting_rows)); + } + + if (delta.x() != 0 && ui->m_table_view->horizontalScrollBar()) + { + const bool counting_columns = + ui->m_table_view->horizontalScrollMode() == QAbstractItemView::ScrollPerItem; + QScrollBar *bar = ui->m_table_view->horizontalScrollBar(); + bar->setValue(bar->value() - wheelAmount(delta.x(), counting_columns)); + } +} + +/** + @brief MaterialSelectionDialog::reload + Read the material file again, so that anything the user saved from his + spreadsheet since the dialog was built shows up. +*/ +void MaterialSelectionDialog::reload() +{ + QString error; + MaterialListData data; + + if (!MaterialList::load(m_path, &data, &error)) + { + QET::QetMessageBox::warning(this, + tr("Lecture impossible"), + tr("Impossible de lire le fichier :\n%1\n%2") + .arg(m_path, error)); + data = MaterialListData(); + } + + m_data = data; + m_model->setMaterial(m_data.columns, m_data.records); + updateCount(); +} + +/** + @brief MaterialSelectionDialog::updateCount + Tell how many entries are shown. +*/ +void MaterialSelectionDialog::updateCount() +{ + const int total = m_model->rowCount(); + const int shown = m_proxy->rowCount(); + + if (m_proxy->tokens().isEmpty()) { + ui->m_count_label->setText(tr("Entrées : %1").arg(total)); + } else { + ui->m_count_label->setText(tr("Entrées : %1 sur %2").arg(shown).arg(total)); + } +} + +/** + @brief MaterialSelectionDialog::updateApplyButton + Apply is only meaningful with an entry selected. +*/ +void MaterialSelectionDialog::updateApplyButton() +{ + QPushButton *button = ui->m_button_box->button(QDialogButtonBox::Ok); + if (button) { + button->setEnabled(!selectedRecord().values.isEmpty()); + } +} + +/** + @brief MaterialSelectionDialog::searchChanged + Refilter the table each time the search field changes. + @param text +*/ +void MaterialSelectionDialog::searchChanged(const QString &text) +{ + m_proxy->setTokens(text.simplified().split(QLatin1Char(' '), Qt::SkipEmptyParts)); + updateCount(); + updateApplyButton(); +} + +/** + @brief MaterialSelectionDialog::selectedRecord + @return the selected entry, a null one when nothing is selected +*/ +MaterialRecord MaterialSelectionDialog::selectedRecord() const +{ + if (!m_proxy || !ui->m_table_view->selectionModel()) { + return MaterialRecord(); + } + + const QModelIndexList rows = ui->m_table_view->selectionModel()->selectedRows(); + if (rows.isEmpty()) { + return MaterialRecord(); + } + + const QModelIndex source = m_proxy->mapToSource(rows.first()); + if (!source.isValid()) { + return MaterialRecord(); + } + + return m_model->record(source.row()); +} + +/** + @brief MaterialSelectionDialog::selectRecord + Select the row holding that record, scrolling to it. + @param record + @return true when the record was found and is visible +*/ +bool MaterialSelectionDialog::selectRecord(const MaterialRecord &record) +{ + const QList records = m_model->records(); + int row = -1; + //The newest match wins : appending an entry twice in a row selects + //the line which was just written. + for (int i = records.size() - 1; i >= 0; --i) + { + if (records.at(i) == record) + { + row = i; + break; + } + } + if (row < 0) { + return false; + } + + const QModelIndex proxy = m_proxy->mapFromSource(m_model->index(row, 0)); + if (!proxy.isValid()) { + return false; + } + + ui->m_table_view->setCurrentIndex(proxy); + ui->m_table_view->selectionModel()->select( + proxy, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + ui->m_table_view->scrollTo(proxy); + updateApplyButton(); + + return true; +} + +/** + @brief MaterialSelectionDialog::accept + Refuse to close the dialog without a selection: "Apply" with nothing + chosen would silently do nothing. +*/ +void MaterialSelectionDialog::accept() +{ + if (selectedRecord().values.isEmpty()) + { + QET::QetMessageBox::information(this, + tr("Aucune sélection"), + tr("Sélectionnez d'abord un article dans la liste.")); + return; + } + + QDialog::accept(); +} + +/** + @brief MaterialSelectionDialog::on_m_new_entry_btn_clicked + Open the entry form and, once it is filled, append the article to the + material file, then select it in the table. +*/ +void MaterialSelectionDialog::on_m_new_entry_btn_clicked() +{ + QStringList columns = m_data.columns; + if (columns.isEmpty()) { + columns = MaterialList::defaultColumns(); + } + + MaterialEntryDialog entry_dialog(columns, this); + if (entry_dialog.exec() != QDialog::Accepted) { + return; + } + + const MaterialRecord record = entry_dialog.record(); + + QString error; + if (!MaterialList::appendRecord(m_path, record, &error)) + { + QET::QetMessageBox::critical(this, + tr("Écriture impossible"), + tr("Impossible d'écrire dans le fichier :\n%1\n%2") + .arg(m_path, error)); + return; + } + + //The file is read again so the new line is found where it really + //is, and not guessed from where it was before the write. + reload(); + + //A search hiding the new article would leave the user with a form + //he just filled and no line to see. + if (!selectRecord(record)) + { + ui->m_search_edit->clear(); + selectRecord(record); + } +} diff --git a/sources/materiallist/materialselectiondialog.h b/sources/materiallist/materialselectiondialog.h new file mode 100644 index 000000000..9b3012b7b --- /dev/null +++ b/sources/materiallist/materialselectiondialog.h @@ -0,0 +1,134 @@ +/* + 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 . +*/ +#ifndef MATERIALSELECTIONDIALOG_H +#define MATERIALSELECTIONDIALOG_H + +#include "../materiallist/materiallist.h" + +#include +#include +#include + +class QModelIndex; +class QItemSelectionModel; +class QWheelEvent; + +namespace Ui { + class MaterialSelectionDialog; +} + +/** + @brief Table model showing a material file. + + Every column of the file is a column of the model, named the way the + user reads it (translated), so the dialog stays a plain read only + table whatever the file contains. +*/ +class MaterialTableModel : public QAbstractTableModel +{ + Q_OBJECT + + public: + explicit MaterialTableModel(QObject *parent = nullptr); + + void setMaterial(const QStringList &columns, const QList &records); + QStringList columns() const {return m_columns;} + QList records() const {return m_records;} + MaterialRecord record(int row) const; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + + private: + QStringList m_columns; + QList m_records; +}; + +/** + @brief Filter keeping only the rows matching every word typed by the + user, whatever the column each word is found in. + + "Hilfsschalter Schneider" keeps the rows holding both words, so adding + the manufacturer to the search narrows the result down, which is the + behaviour of a bill of material lookup. +*/ +class MaterialFilterProxy : public QSortFilterProxyModel +{ + Q_OBJECT + + public: + explicit MaterialFilterProxy(MaterialTableModel *model, QObject *parent = nullptr); + + void setTokens(const QStringList &tokens); + QStringList tokens() const {return m_tokens;} + + protected: + bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + + private: + MaterialTableModel *m_model = nullptr; + QStringList m_tokens; +}; + +/** + @brief The dialog listing every article of the material file, letting + the user search one and hand it back to the element properties. + + Three buttons: apply the selected row, do nothing, or add a new row to + the file. +*/ +class MaterialSelectionDialog : public QDialog +{ + Q_OBJECT + + public: + explicit MaterialSelectionDialog(const QString &path, + QWidget *parent = nullptr); + ~MaterialSelectionDialog() override; + + MaterialRecord selectedRecord() const; + void accept() override; + void done(int result) override; + bool eventFilter(QObject *watched, QEvent *event) override; + + private slots: + void on_m_new_entry_btn_clicked(); + void searchChanged(const QString &text); + + private: + void reload(); + void restoreViewState(); + void saveViewState(); + void resetSorting(); + void scrollTable(QWheelEvent *event); + void updateCount(); + void updateApplyButton(); + bool selectRecord(const MaterialRecord &record); + + //ATTRIBUTES + private: + Ui::MaterialSelectionDialog *ui; + MaterialTableModel *m_model = nullptr; + MaterialFilterProxy *m_proxy = nullptr; + QString m_path; + MaterialListData m_data; +}; + +#endif // MATERIALSELECTIONDIALOG_H diff --git a/sources/materiallist/materialselectiondialog.ui b/sources/materiallist/materialselectiondialog.ui new file mode 100644 index 000000000..a5cbd7601 --- /dev/null +++ b/sources/materiallist/materialselectiondialog.ui @@ -0,0 +1,150 @@ + + + MaterialSelectionDialog + + + + 0 + 0 + 1120 + 800 + + + + + 780 + 560 + + + + Liste de matériaux + + + true + + + + + + + + Rechercher : + + + + + + + Tous les mots saisis doivent apparaître quelque part dans la ligne + + + true + + + Rechercher dans toutes les colonnes + + + + + + + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::SelectRows + + + QAbstractItemView::SingleSelection + + + true + + + false + + + + + + + Entrées : 0 + + + + + + + + + Ajouter un nouvel article à la liste + + + Nouvelle entrée + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + m_button_box + accepted() + MaterialSelectionDialog + accept() + + + 800 + 540 + + + 100 + 100 + + + + + m_button_box + rejected() + MaterialSelectionDialog + reject() + + + 880 + 540 + + + 100 + 100 + + + + + diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index 517548ad3..63bea4a11 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -20,12 +20,14 @@ #include "../../qetapp.h" #include "../../qeticons.h" #include "ui_generalconfigurationpage.h" +#include "../../materiallist/materiallist.h" #include "../../utils/qetsettings.h" #include "../../utils/qetutils.h" #include "../../qetmessagebox.h" #include "../../textgrid.h" #include "../nokde/kcolorbutton.h" #include +#include #include #include @@ -228,6 +230,17 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : ui->m_user_macros_path_cb->blockSignals(false); } + //MATERIAL FILE + path = MaterialList::configuredPath(); + ui->m_material_list_path_le->setText(path); + if (path.isEmpty()) + { + ui->m_material_list_path_le->setPlaceholderText( + tr("Non configuré (par défaut : %1)", + "hint shown in the material file field when no file is configured yet") + .arg(MaterialList::defaultPath())); + } + fillLang(); } @@ -412,6 +425,12 @@ void GeneralConfigurationPage::applyConf() if (path != settings.value("elements-collections/macros-path").toString()) { QETApp::resetCollectionsPath(); } + + //MATERIAL FILE + //Unlike the collections, the material file is a plain file, it is + //kept as chosen even when it doesn't exist yet : the user may well + //point QElectroTech at a file he intends to write later. + MaterialList::setConfiguredPath(ui->m_material_list_path_le->text().trimmed()); } /** @@ -617,6 +636,70 @@ void GeneralConfigurationPage::on_m_user_macros_path_cb_currentIndexChanged(int } } +/** + @brief GeneralConfigurationPage::on_m_material_list_browse_pb_clicked + Let the user pick an existing material file. +*/ +void GeneralConfigurationPage::on_m_material_list_browse_pb_clicked() +{ + QString start_dir = ui->m_material_list_path_le->text(); + start_dir = start_dir.isEmpty() + ? QETApp::documentDir() + : QFileInfo(start_dir).absolutePath(); + + const QString path = QFileDialog::getOpenFileName( + this, + tr("Sélectionner le fichier de la liste de matériaux"), + start_dir, + tr("Fichiers csv (*.csv)")); + + if (!path.isEmpty()) { + ui->m_material_list_path_le->setText(path); + } +} + +/** + @brief GeneralConfigurationPage::on_m_material_list_create_pb_clicked + Create the material file with its header line, so the columns are + known before the user fills them from his spreadsheet. +*/ +void GeneralConfigurationPage::on_m_material_list_create_pb_clicked() +{ + QString path = ui->m_material_list_path_le->text(); + path = path.isEmpty() + ? MaterialList::defaultPath() + : QFileInfo(path).absolutePath() + QLatin1Char('/') + MaterialList::defaultFileName(); + + path = QFileDialog::getSaveFileName( + this, + tr("Créer le fichier de la liste de matériaux"), + path, + tr("Fichiers csv (*.csv)")); + if (path.isEmpty()) { + return; + } + if (QFileInfo(path).suffix().isEmpty()) { + path += QStringLiteral(".csv"); + } + + //An existing file is kept as it is : this button creates the + //header, it never overwrites a catalogue. + if (MaterialList::isEmptyFile(path)) + { + QString error; + if (!MaterialList::createFile(path, &error)) + { + QET::QetMessageBox::critical(this, + tr("Création impossible"), + tr("Impossible de créer le fichier :\n%1\n%2") + .arg(path, error)); + return; + } + } + + ui->m_material_list_path_le->setText(path); +} + void GeneralConfigurationPage::on_m_indi_text_font_pb_clicked() { bool ok; diff --git a/sources/ui/configpage/generalconfigurationpage.h b/sources/ui/configpage/generalconfigurationpage.h index 015ec8cc1..9a3702805 100644 --- a/sources/ui/configpage/generalconfigurationpage.h +++ b/sources/ui/configpage/generalconfigurationpage.h @@ -47,6 +47,8 @@ class GeneralConfigurationPage : public ConfigPage void on_m_company_tbt_path_cb_currentIndexChanged(int index); void on_m_custom_tbt_path_cb_currentIndexChanged(int index); void on_m_user_macros_path_cb_currentIndexChanged(int index); + void on_m_material_list_browse_pb_clicked(); + void on_m_material_list_create_pb_clicked(); void on_m_indi_text_font_pb_clicked(); void on_MaxPartsElementEditorList_sb_valueChanged(int value); void on_DiagramEditor_Grid_PointSize_min_sb_valueChanged(int value); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index 027b73704..e0251641e 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -459,6 +459,47 @@ + + + + Fichier de la liste de matériaux + + + + + + + + + Répertoire de matériaux utilisé pour renseigner les articles d'un élément + + + true + + + Non configuré + + + + + + + Parcourir... + + + + + + + Créer le fichier de la liste avec ses en-têtes + + + Créer... + + + + + diff --git a/sources/ui/elementinfopartwidget.cpp b/sources/ui/elementinfopartwidget.cpp index 0f57c6ec6..70df10998 100644 --- a/sources/ui/elementinfopartwidget.cpp +++ b/sources/ui/elementinfopartwidget.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include @@ -46,6 +47,14 @@ ElementInfoPartWidget::ElementInfoPartWidget( ui->setupUi(this); ui->label_->setText(translated_key); ui->m_erase_text->setVisible(false); + ui->m_material_btn->setVisible(false); + + //The line edit must swallow the whole width of the row: without + //this the material button column would steal room from it. + ui->gridLayout_2->setColumnStretch(0, 1); + + connect(ui->m_material_btn, &QToolButton::clicked, + this, &ElementInfoPartWidget::materialButtonClicked); if (key_ == QETInformation::ELMT_WIDTH || key_ == QETInformation::ELMT_HEIGHT || key_ == QETInformation::ELMT_DEPTH) { @@ -174,6 +183,16 @@ void ElementInfoPartWidget::setEraseTextVisible(bool visible) m_show_erase = visible; } +/** + @brief ElementInfoPartWidget::setMaterialButtonVisible + Show or hide the "..." button opening the material file for this row. + @param visible +*/ +void ElementInfoPartWidget::setMaterialButtonVisible(bool visible) +{ + ui->m_material_btn->setVisible(visible); +} + /** @brief ElementInfoPartWidget::setEraseTextChecked @param check diff --git a/sources/ui/elementinfopartwidget.h b/sources/ui/elementinfopartwidget.h index e6837cb83..019cf9858 100644 --- a/sources/ui/elementinfopartwidget.h +++ b/sources/ui/elementinfopartwidget.h @@ -56,10 +56,12 @@ class ElementInfoPartWidget : public QWidget void setEraseTextVisible (bool visible); void setEraseTextChecked (bool check); Qt::CheckState EraseTextCheckState ()const; + void setMaterialButtonVisible (bool visible); signals: void textEdited (const QString & text); void textChanged (const QString & text); + void materialButtonClicked(); //ATTRIBUTES private slots: diff --git a/sources/ui/elementinfopartwidget.ui b/sources/ui/elementinfopartwidget.ui index d99ff9141..37d285299 100644 --- a/sources/ui/elementinfopartwidget.ui +++ b/sources/ui/elementinfopartwidget.ui @@ -65,6 +65,25 @@ + + + + Choisir un article dans la liste de matériaux + + + … + + + false + + + true + + + Qt::ToolButtonTextOnly + + + diff --git a/sources/ui/elementinfowidget.cpp b/sources/ui/elementinfowidget.cpp index 885ef0cdc..f009bb374 100644 --- a/sources/ui/elementinfowidget.cpp +++ b/sources/ui/elementinfowidget.cpp @@ -20,8 +20,11 @@ #include #include #include "../diagram.h" +#include "../materiallist/materiallist.h" +#include "../materiallist/materialselectiondialog.h" #include "../qetapp.h" #include "../qetgraphicsitem/element.h" +#include "../qetmessagebox.h" #include "../dataBase/projectdatabase.h" #include "../qetinformation.h" #include "../qetproject.h" @@ -222,6 +225,8 @@ void ElementInfoWidget::buildInterface() m_eipw_list << eipw; } + setupMaterialButtons(); + m_add_custom_property_btn = new QPushButton(tr("Ajouter une propriété personnalisée"), this); connect(m_add_custom_property_btn, &QPushButton::clicked, this, [this]() { addCustomProperty(); }); ui->scroll_vlayout->addWidget(m_add_custom_property_btn); @@ -332,6 +337,150 @@ ElementInfoPartWidget *ElementInfoWidget::infoPartWidgetForKey(const QString &ke return nullptr; } +/** + @brief ElementInfoWidget::setupMaterialButtons + Show the "..." button opening the material file on the description row + of each block: one for the main article, one per auxiliary article + which really has a description row. + + Only that row carries the button: the same window fills the whole + block, so repeating it on every field of the block would just offer + five ways to do the same thing. +*/ +void ElementInfoWidget::setupMaterialButtons() +{ + auto register_button = [this](ElementInfoPartWidget *eipw, int block) + { + eipw->setMaterialButtonVisible(true); + connect(eipw, &ElementInfoPartWidget::materialButtonClicked, this, + [this, block]() { materialFromFile(block); }); + }; + + //Main article. A terminal has no description row: its article + //number is the one which identifies it, so the button sits there. + if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(QETInformation::ELMT_DESCRIPTION)) { + register_button(eipw, 0); + } else if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(QETInformation::ELMT_DESIGNATION)) { + register_button(eipw, 0); + } + + //Auxiliary articles 1 to 4. + const QStringList auxiliary_keys = { + QETInformation::ELMT_DESCRIPTION_AUX1, + QETInformation::ELMT_DESCRIPTION_AUX2, + QETInformation::ELMT_DESCRIPTION_AUX3, + QETInformation::ELMT_DESCRIPTION_AUX4 + }; + + for (int block = 1; block <= 4 && block <= auxiliary_keys.size(); ++block) + { + if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(auxiliary_keys.at(block - 1))) { + register_button(eipw, block); + } + } +} + +/** + @brief ElementInfoWidget::materialFromFile + Open the material file, let the user pick an article, and write it + into the block the button belongs to. + @param block 0 for the main article, 1 to 4 for an auxiliary article +*/ +void ElementInfoWidget::materialFromFile(int block) +{ + //Nothing configured yet: the standard location is used, the same + //one the preferences offer to create, so that the catalogue can be + //reached from here without asking for a file to be saved first. + QString path = MaterialList::configuredPath(); + if (path.isEmpty()) + { + path = MaterialList::defaultPath(); + MaterialList::setConfiguredPath(path); + } + + //A missing or empty file has no entry to pick: offer to write the + //header line instead of opening an empty window. + if (MaterialList::isEmptyFile(path)) + { + const auto answer = QET::QetMessageBox::question( + this, + tr("Liste de matériaux absente"), + tr("Aucun fichier de liste de matériaux n'existe à cet emplacement :\n" + "%1\n\nLe créer ?", "message asking to create the material file").arg(path), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::Yes); + + if (answer != QMessageBox::Yes) { + return; + } + + QString error; + if (!MaterialList::createFile(path, &error)) + { + QET::QetMessageBox::critical(this, + tr("Création impossible"), + tr("Impossible de créer le fichier :\n%1\n%2") + .arg(path, error)); + return; + } + } + + //The catalogue opens on the whole list : the search field starts + //empty, whatever the block already holds. + MaterialSelectionDialog dialog(path, this); + if (dialog.exec() == QDialog::Accepted) { + applyMaterialRecord(dialog.selectedRecord(), block); + } +} + +/** + @brief ElementInfoWidget::applyMaterialRecord + Write a catalogue entry into the fields of one block. + + Only the fields of that block are touched: applied to an auxiliary + article, the entry never reaches the main article. And a cell which is + empty in the file leaves the current value alone, so picking an entry + describing only the order reference never wipes a comment. + @param record the entry taken from the material file + @param block 0 for the main article, 1 to 4 for an auxiliary article +*/ +void ElementInfoWidget::applyMaterialRecord(const MaterialRecord &record, int block) +{ + if (record.values.isEmpty()) { + return; + } + + //Fill the fields without letting the live edit push one undo + //command per line edit: the whole entry must be undoable at once. + const bool live_edit = m_live_edit; + if (live_edit) { + disableLiveEdit(); + } + + for (const QString &column : MaterialList::columnsForBlock(block)) + { + const QString value = record.value(column); + if (value.isEmpty()) { + continue; + } + + const QString key = MaterialList::elementInfoKey(column, block); + if (key.isEmpty() || !QETInformation::elementInfoKeys().contains(key)) { + continue; + } + + if (ElementInfoPartWidget *eipw = infoPartWidgetForKey(key)) { + eipw->setText(value); + } + } + + if (live_edit) { + enableLiveEdit(); + } + + apply(); +} + /** @brief ElementInfoWidget::updateSuggestions Offer, for each information, the values already used by the other diff --git a/sources/ui/elementinfowidget.h b/sources/ui/elementinfowidget.h index d959cf19a..9eaeefdba 100644 --- a/sources/ui/elementinfowidget.h +++ b/sources/ui/elementinfowidget.h @@ -30,6 +30,7 @@ class CustomElementInfoPartWidget; class ChangeElementInformationCommand; class QCheckBox; class QPushButton; +struct MaterialRecord; namespace Ui { class ElementInfoWidget; @@ -64,6 +65,9 @@ class ElementInfoWidget : public AbstractElementPropertiesEditorWidget private: void buildInterface(); + void setupMaterialButtons(); + void materialFromFile(int block); + void applyMaterialRecord(const MaterialRecord &record, int block); ElementInfoPartWidget *infoPartWidgetForKey(const QString &key) const; QStringList predefinedKeys() const; void updateSuggestions();