diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index 9297115f7..41380463c 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -475,6 +475,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ElementsCollection/elementstreeview.h ${QET_DIR}/sources/ElementsCollection/fileelementcollectionitem.cpp ${QET_DIR}/sources/ElementsCollection/fileelementcollectionitem.h + ${QET_DIR}/sources/ElementsCollection/qetlabelsfile.cpp + ${QET_DIR}/sources/ElementsCollection/qetlabelsfile.h ${QET_DIR}/sources/ElementsCollection/terminaluuids.cpp ${QET_DIR}/sources/ElementsCollection/terminaluuids.h ${QET_DIR}/sources/ElementsCollection/xmlelementcollection.cpp @@ -800,6 +802,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/ui/multipastedialog.h ${QET_DIR}/sources/ui/potentialselectordialog.cpp ${QET_DIR}/sources/ui/potentialselectordialog.h + ${QET_DIR}/sources/ui/prefixconfigurationdialog.cpp + ${QET_DIR}/sources/ui/prefixconfigurationdialog.h ${QET_DIR}/sources/ui/projectpropertiesdialog.cpp ${QET_DIR}/sources/ui/projectpropertiesdialog.h ${QET_DIR}/sources/ui/reportpropertiewidget.cpp diff --git a/sources/ElementsCollection/qetlabelsfile.cpp b/sources/ElementsCollection/qetlabelsfile.cpp new file mode 100644 index 000000000..6c2fdd357 --- /dev/null +++ b/sources/ElementsCollection/qetlabelsfile.cpp @@ -0,0 +1,663 @@ +/* + 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 "qetlabelsfile.h" + +#include +#include +#include +#include + +/** + @brief QetLabelsFile::labelsFilePath + @param collection_dir : an elements collection directory + @return the path of the qet_labels.xml file of that collection +*/ +QString QetLabelsFile::labelsFilePath(const QString &collection_dir) +{ + return QDir(collection_dir).filePath(QStringLiteral("qet_labels.xml")); +} + +/** + @brief QetLabelsFile::scanFolders + @param collection_dir : an elements collection directory + @return every directory of the collection, each entry being the list of + the directory names from the collection root down to the directory + itself (["level_one", "level_two", ...]), in tree order : a parent + always comes before its children, siblings are sorted by name. + Only plain, visible directories are reported : symbolic links are + skipped (a link pointing back up the tree would never end) as are + hidden directories such as a stray .git. +*/ +QList QetLabelsFile::scanFolders(const QString &collection_dir) +{ + QList out; + if (collection_dir.isEmpty()) { + return out; + } + scanRec(collection_dir, QStringList(), out); + return out; +} + +/** + @brief QetLabelsFile::scanRec + Recursive helper of scanFolders() +*/ +void QetLabelsFile::scanRec(const QString &path, const QStringList &relative_path, QList &out) +{ + const QFileInfoList sub_dirs = QDir(path).entryInfoList( + QDir::Dirs | QDir::NoDotAndDotDot | QDir::NoSymLinks, + QDir::Name); + for (const QFileInfo &info : sub_dirs) { + QStringList current = relative_path; + current << info.fileName(); + out.append(current); + scanRec(info.absoluteFilePath(), current, out); + } +} + +/** + @brief QetLabelsFile::prefixForPath + Look up a prefix for @a path (path[dirLevel] outermost, path[1] the + deepest directory; path[0], the element's own file name, is never + matched) in the qet_labels.xml at @a filepath. + + @return the prefix that applies, or a null QString if the file + cannot be read, is not well-formed, or does not describe this + path at all (as opposed to describing it with no prefix + anywhere along it, which is a non-null empty string). + + @see prefixInDocument() for the description of the lookup itself +*/ +QString QetLabelsFile::prefixForPath(const QString &filepath, const QStringList &path, int dirLevel) +{ + QFile file(filepath); + if (!file.open(QFile::ReadOnly | QFile::Text)) { + return QString(); + } + + QDomDocument document; + if (!document.setContent(&file)) { + return QString(); + } + + return prefixInDocument(document, path, dirLevel); +} + +/** + @brief QetLabelsFile::prefixInDocument + Look up a prefix for @a path (path[dirLevel] outermost, path[1] the + deepest directory; path[0], the element's own file name, is never + matched) in @a document. + + Descends through nested \ elements matching + path[dirLevel], path[dirLevel-1], ..., path[1] in turn, considering + only *direct* children at each step -- unlike a flat token scan, + this cannot be fooled by a same-named category living elsewhere in + the document at the wrong nesting depth (bugtracker #671 item 5). + + At each matched level, that category's own \ child -- even + an empty one -- overrides whatever a shallower ancestor already + provided, so an explicit empty \ cancels inheritance + rather than silently falling back to it (the behaviour requested in + PR #686 review). A category with no \ child at all leaves + the inherited value untouched, which is how a directory with no + prefix of its own comes to inherit its parent's, as the file's own + header comment documents. + + @return the prefix that applies, or a null QString if the document + does not describe this path at all (as opposed to describing it + with no prefix anywhere along it, which is a non-null empty + string). +*/ +QString QetLabelsFile::prefixInDocument(const QDomDocument &document, const QStringList &path, int dirLevel) +{ + QDomElement node = document.documentElement(); + if (node.isNull()) { + return QString(); + } + + QString prefix; + for (int i = dirLevel ; i >= 1 ; --i) { + QDomElement child = node.firstChildElement(QStringLiteral("category")); + while (!child.isNull() + && child.attribute(QStringLiteral("name")) != path[i]) { + child = child.nextSiblingElement(QStringLiteral("category")); + } + if (child.isNull()) { + return QString(); + } + node = child; + + const QDomElement own = node.firstChildElement(QStringLiteral("prefix")); + if (!own.isNull()) { + //readElementText()'s null-vs-empty distinction that PR + //#686 needed for the old QXmlStreamReader-based lookup + //has a QDomElement equivalent: text() on an empty + //element can itself come back null depending on how the + //XML was written, so the same explicit fallback applies + //-- an empty QString here means "found, deliberately + //blank", not "not found". + prefix = own.text(); + if (prefix.isNull()) { + prefix = QString(""); + } + } + } + return prefix; +} + +/** + @brief QetLabelsFile::parse + Read @a device into @a document and, when it cannot be read as XML, + tell what is wrong with it in @a reason. + The Qt version split is the same one used by edzpart.cpp and + cli_export.cpp : QDomDocument::ParseResult only exists since Qt 6.5. + @return true when the document is well formed. +*/ +bool QetLabelsFile::parse(QIODevice &device, QDomDocument &document, QString *reason) +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + const auto result = document.setContent(&device); + if (result) { + return true; + } + if (reason != nullptr) { + *reason = tr("erreur de syntaxe à la ligne %1, colonne %2 :\n%3") + .arg(result.errorLine) + .arg(result.errorColumn) + .arg(result.errorMessage); + } + return false; +#else + QString message; + int line = 0; + int column = 0; + if (document.setContent(&device, &message, &line, &column)) { + return true; + } + if (reason != nullptr) { + *reason = tr("erreur de syntaxe à la ligne %1, colonne %2 :\n%3") + .arg(line) + .arg(column) + .arg(message); + } + return false; +#endif +} + +/** + @brief QetLabelsFile::load + Read the qet_labels.xml of the collection @a collection_dir. + Nothing is written : + - when the file does not exist yet, an empty \ document is + kept in memory and will only be created on the first save() + - when the file exists but cannot be read, is not well formed or does + not have \ as its root element, nothing is written : an + empty document is used instead, and a copy of the unusable file is + only made by save(), right before it is replaced. isBroken() and + brokenReason() tell what is wrong with it, so the caller can offer + repairing the file rather than silently discarding its content. + @return false only when no collection directory was given. It is + save() that reports a file it could not copy aside. +*/ +bool QetLabelsFile::load(const QString &collection_dir) +{ + m_file_path.clear(); + m_backup_path.clear(); + m_error.clear(); + m_broken_reason.clear(); + m_snapshot.clear(); + m_file_exists = false; + m_force_save = false; + m_broken = false; + m_document = QDomDocument(); + + if (collection_dir.isEmpty()) { + m_error = tr("Aucun répertoire de collection n'a été donné."); + return false; + } + m_file_path = labelsFilePath(collection_dir); + + QFile file(m_file_path); + if (!file.exists()) { + createEmptyDocument(); + m_snapshot = serialize(m_document); + return true; + } + m_file_exists = true; + + QDomDocument document; + bool well_formed = false; + if (file.open(QFile::ReadOnly | QFile::Text)) { + well_formed = parse(file, document, &m_broken_reason); + if (well_formed + && document.documentElement().tagName() != QLatin1String("labels")) { + well_formed = false; + m_broken_reason = tr("l'élément racine <%1> n'est pas .") + .arg(document.documentElement().tagName()); + } + file.close(); + } else { + m_broken_reason = tr("le fichier n'a pas pu être ouvert : %1") + .arg(file.errorString()); + } + + if (well_formed) { + m_document = document; + } else { + //The file is unreadable or malformed : leave it alone and work + //on an empty document. m_force_save makes sure the file is + //replaced as soon as the caller validates, even though the + //empty document we start from serializes just fine - and it is + //also what tells save() to copy the file aside first. + m_broken = true; + createEmptyDocument(); + m_force_save = true; + } + m_snapshot = serialize(m_document); + return true; +} + +/** + @brief QetLabelsFile::createEmptyDocument + Reset the in memory document to an empty \ document, preceded + by the comment explaining how the prefixes are meant to be written. +*/ +void QetLabelsFile::createEmptyDocument() +{ + m_document = QDomDocument(); + m_document.appendChild(m_document.createProcessingInstruction( + QStringLiteral("xml"), + QStringLiteral("version=\"1.0\" encoding=\"utf-8\""))); + + const QString comment = tr( + "Fichier de préfixes (étiquettes) de la collection utilisateur.\n" + "Un préfixe est attribué à chaque dossier : les éléments d'un dossier\n" + "reprennent le préfixe de ce dossier, sauf s'ils portent eux-mêmes une\n" + "étiquette. Un dossier sans préfixe reprend celui de son dossier parent.\n" + "Ce fichier est créé et modifié par QElectroTech (Programme de réglages :\n" + "Configurer les préfixes…), mais reste modifiable à la main."); + m_document.appendChild(m_document.createComment(comment)); + + m_document.appendChild(m_document.createElement(QStringLiteral("labels"))); +} + +/** + @brief QetLabelsFile::backupBrokenFile + Keep a copy of the unreadable file before it gets overwritten by the + next save(). + @return the path of the created backup, or an empty QString if the + copy failed. +*/ +QString QetLabelsFile::backupBrokenFile() +{ + QString candidate = m_file_path + QStringLiteral(".bak"); + int counter = 1; + while (QFile::exists(candidate) && counter < 1000) { + candidate = m_file_path + QStringLiteral(".bak.") + QString::number(counter++); + } + if (!QFile::copy(m_file_path, candidate)) { + return QString(); + } + return candidate; +} + +/** + @brief QetLabelsFile::prefix + @param relative_path : a directory of the collection, from the + collection root down to the directory itself + @return the prefix stored for that very directory, an empty QString + when it has no prefix of its own -- which means it inherits the + prefix of its parent directory, and is shown with an empty field + by the editor. The inherited value is never reported here: a + field the user leaves empty must keep inheriting. +*/ +QString QetLabelsFile::prefix(const QStringList &relative_path) const +{ + QDomElement node = m_document.documentElement(); + for (const QString &name : relative_path) { + node = directChildCategory(node, name); + if (node.isNull()) { + return QString(); + } + } + + const QDomElement own = node.firstChildElement(QStringLiteral("prefix")); + if (own.isNull()) { + return QString(); + } + const QString value = own.text(); + return value.isNull() ? QString() : value; +} + +/** + @brief QetLabelsFile::hasPrefix + @return true when @a relative_path owns a \ child of its own, + even an empty one. prefix() alone cannot tell that apart from a + category without any \ : both give it no value, while an + empty \ cancels the inheritance where a category + without one inherits. +*/ +bool QetLabelsFile::hasPrefix(const QStringList &relative_path) const +{ + QDomElement node = m_document.documentElement(); + for (const QString &name : relative_path) { + node = directChildCategory(node, name); + if (node.isNull()) { + return false; + } + } + + return !node.firstChildElement(QStringLiteral("prefix")).isNull(); +} + +/** + @brief QetLabelsFile::directChildCategory + @return the \ child of @a parent named @a name, or a null + element when there is none. Only direct children are considered, + the same way the prefix lookup descends the document. +*/ +QDomElement QetLabelsFile::directChildCategory(const QDomElement &parent, const QString &name) +{ + QDomElement child = parent.firstChildElement(QStringLiteral("category")); + while (!child.isNull() + && child.attribute(QStringLiteral("name")) != name) { + child = child.nextSiblingElement(QStringLiteral("category")); + } + return child; +} + +/** + @brief QetLabelsFile::categoryForPath + @param relative_path : a directory of the collection, from the + collection root down to the directory itself + @param create : when true, the missing \ elements are + created on the way down + @return the \ element describing @a relative_path, or a + null element when it does not exist and @a create is false. +*/ +QDomElement QetLabelsFile::categoryForPath(const QStringList &relative_path, bool create) +{ + QDomElement node = m_document.documentElement(); + for (const QString &name : relative_path) { + if (node.isNull()) { + break; + } + QDomElement child = directChildCategory(node, name); + if (child.isNull()) { + if (!create) { + return QDomElement(); + } + child = m_document.createElement(QStringLiteral("category")); + child.setAttribute(QStringLiteral("name"), name); + //A prefix always comes after the child categories, as + //documented in the header of the shipped labels files : + //insert the new category before the prefix, then push the + //prefix back to the end when the file did not follow that + //convention in the first place. A node nothing is inserted + //into is never repositioned, so a hand formatted file stays + //byte for byte the same as long as it is not modified. + QDomElement prefix = node.firstChildElement(QStringLiteral("prefix")); + if (prefix.isNull()) { + node.appendChild(child); + } else { + node.insertBefore(child, prefix); + if (!prefix.nextSibling().isNull()) { + node.appendChild(prefix); + } + } + } + node = child; + } + return node; +} + +/** + @brief QetLabelsFile::ensureStructure + Create, in memory, one \ element for every directory of the + collection, nesting them the way the directories are nested. + Existing elements -- categories, prefixes and comments alike -- are + left where and how they are: only missing categories are added, and + those get no \ child, which is exactly "inherit the parent's + prefix". + @param folders : as returned by scanFolders() +*/ +void QetLabelsFile::ensureStructure(const QList &folders) +{ + for (const QStringList &folder : folders) { + categoryForPath(folder, true); + } +} + +/** + @brief QetLabelsFile::setPrefix + Store @a prefix as the prefix of the directory @a relative_path, + creating the missing categories on the way. + An empty @a prefix means "that directory has no prefix of its own" : + any \ element it had is removed so the lookup falls back to + the prefix of the parent directory. +*/ +void QetLabelsFile::setPrefix(const QStringList &relative_path, const QString &prefix) +{ + if (relative_path.isEmpty()) { + return; + } + QDomElement node = categoryForPath(relative_path, true); + if (node.isNull()) { + return; + } + + QDomElement element = node.firstChildElement(QStringLiteral("prefix")); + if (prefix.isEmpty()) { + if (!element.isNull()) { + node.removeChild(element); + } + return; + } + + if (element.isNull()) { + element = m_document.createElement(QStringLiteral("prefix")); + node.appendChild(element); + } + while (!element.firstChild().isNull()) { + element.removeChild(element.firstChild()); + } + element.appendChild(m_document.createTextNode(prefix)); + //keep the convention : the prefix stays after the child categories + if (!element.nextSibling().isNull()) { + node.appendChild(element); + } +} + +/** + @brief QetLabelsFile::collectOrphans + Recursive helper shared by orphanPaths() and removeOrphans(). + Appends to @a orphans the top most \ elements whose path is + not in @a folders_key -- their children go away with them. +*/ +void QetLabelsFile::collectOrphans(const QDomElement &parent, const QString &relative_path, const QSet &folders_key, QList &orphans) +{ + for (QDomElement child = parent.firstChildElement(QStringLiteral("category")); + !child.isNull(); + child = child.nextSiblingElement(QStringLiteral("category"))) { + const QString path = relative_path.isEmpty() + ? child.attribute(QStringLiteral("name")) + : relative_path + QLatin1Char('/') + child.attribute(QStringLiteral("name")); + if (folders_key.contains(path)) { + collectOrphans(child, path, folders_key, orphans); + } else { + orphans.append(child); + } + } +} + +/** + @brief QetLabelsFile::orphanPaths + @param folders : as returned by scanFolders() + @return the paths of the \ elements of the document that do + not describe any existing directory of the collection, e.g. a + directory the user deleted or renamed, or entries written by hand + in another layout. The editor asks the user what to do with them. +*/ +QStringList QetLabelsFile::orphanPaths(const QList &folders) const +{ + QSet folders_key; + for (const QStringList &folder : folders) { + folders_key.insert(folder.join(QLatin1Char('/'))); + } + + QList orphans; + const QDomElement root = m_document.documentElement(); + if (!root.isNull()) { + collectOrphans(root, QString(), folders_key, orphans); + } + + QStringList out; + for (const QDomElement &element : orphans) { + QStringList names; + QDomElement node = element; + while (!node.isNull() && node.tagName() != QLatin1String("labels")) { + names.prepend(node.attribute(QStringLiteral("name"))); + node = node.parentNode().toElement(); + } + out.append(names.join(QLatin1Char('/'))); + } + return out; +} + +/** + @brief QetLabelsFile::removeOrphans + Remove from the document every category orphanPaths() reports. + @param folders : as returned by scanFolders() + @return the number of removed categories (children included) +*/ +int QetLabelsFile::removeOrphans(const QList &folders) +{ + QStringList orphans = orphanPaths(folders); + if (orphans.isEmpty()) { + return 0; + } + + int removed = 0; + for (const QString &path : orphans) { + QDomElement node = m_document.documentElement(); + const QStringList names = path.split(QLatin1Char('/')); + for (const QString &name : names) { + node = directChildCategory(node, name); + if (node.isNull()) { + break; + } + } + if (node.isNull()) { + continue; + } + node.parentNode().removeChild(node); + ++removed; + } + return removed; +} + +/** + @brief QetLabelsFile::serialize + @return @a document as the text written to the disk : a canonical xml + declaration followed by a two space per level indentation, the + same as the shipped labels files. +*/ +QString QetLabelsFile::serialize(const QDomDocument &document) +{ + QString content = document.toString(2); + + //QDomDocument may or may not emit the xml declaration itself, + //depending on how the document was built, so drop it to always + //write the very same header. ". + const int declaration_end = content.indexOf(QLatin1String("?>")); + if (content.startsWith(QLatin1String(" 0) { + content.remove(0, declaration_end + 2); + } + while (content.startsWith(QLatin1Char('\n'))) { + content.remove(0, 1); + } + + return QStringLiteral("\n") + content; +} + +/** + @brief QetLabelsFile::save + Write the document back to its qet_labels.xml. + The file is only rewritten when the document actually differs from + what was loaded, so a file the user formatted by hand is left alone + when nothing was changed. When the file does not exist yet it is of + course always created. + @return true when there was nothing to write or the file was + successfully written; otherwise false and errorString() tells why. +*/ +bool QetLabelsFile::save() +{ + m_error.clear(); + + if (m_file_path.isEmpty()) { + m_error = tr("Aucun fichier de préfixes à enregistrer."); + return false; + } + + const QString content = serialize(m_document); + if (m_file_exists && !m_force_save && content == m_snapshot) { + return true; + } + + const QDir directory(QFileInfo(m_file_path).absolutePath()); + if (!directory.exists() && !QDir().mkpath(directory.absolutePath())) { + m_error = tr("Le répertoire %1 n'a pas pu être créé.").arg(directory.absolutePath()); + return false; + } + + if (m_force_save && m_file_exists) { + //The file on disk could not be read : copy it aside before it + //is replaced. Doing it here, and not when it is loaded, means + //no copy is left behind when the user chooses to repair the + //file instead of rebuilding it - or simply changes their mind + //and cancels. + m_backup_path = backupBrokenFile(); + if (m_backup_path.isEmpty()) { + m_error = tr("Le fichier %1 n'a pas pu être copié à côté avant d'être remplacé :\nrien n'a été modifié.") + .arg(m_file_path); + return false; + } + } + + QSaveFile file(m_file_path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + m_error = file.errorString(); + return false; + } + if (file.write(content.toUtf8()) < 0) { + m_error = file.errorString(); + file.cancelWriting(); + return false; + } + if (!file.commit()) { + m_error = file.errorString(); + return false; + } + + m_file_exists = true; + m_force_save = false; + m_snapshot = content; + return true; +} diff --git a/sources/ElementsCollection/qetlabelsfile.h b/sources/ElementsCollection/qetlabelsfile.h new file mode 100644 index 000000000..2030c2db5 --- /dev/null +++ b/sources/ElementsCollection/qetlabelsfile.h @@ -0,0 +1,107 @@ +/* + 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 QETLABELSFILE_H +#define QETLABELSFILE_H + +#include +#include +#include +#include +#include +#include + +class QIODevice; + +/** + @brief The QetLabelsFile class + + Represents the qet_labels.xml file of an elements collection : the file + that maps every directory of the collection to the prefix (the ISO/IEC + 81346 label) given to the elements it contains. + + Two kind of users use this class : + - the prefix lookup performed when an element is placed in a project + (prefixForPath(), see also autonum::elementPrefixForLocation()) + - the "Configurer les préfixes…" editor, which loads a file (creating a + skeleton in memory when there is none yet), shows the folder tree of + the collection next to the stored prefixes, and writes everything back + in one go when the user validates. + + Nothing is written to the disk before save() is called, so cancelling the + editor really leaves the collection untouched. +*/ +class QetLabelsFile +{ + Q_DECLARE_TR_FUNCTIONS(QetLabelsFile) + + public: + QetLabelsFile() = default; + + // methods + public: + static QString labelsFilePath(const QString &collection_dir); + static QList scanFolders(const QString &collection_dir); + static QString prefixForPath(const QString &filepath, const QStringList &path, int dirLevel); + static QString prefixInDocument(const QDomDocument &document, const QStringList &path, int dirLevel); + + bool load(const QString &collection_dir); + QString prefix(const QStringList &relative_path) const; + bool hasPrefix(const QStringList &relative_path) const; + QStringList orphanPaths(const QList &folders) const; + void ensureStructure(const QList &folders); + void setPrefix(const QStringList &relative_path, const QString &prefix); + int removeOrphans(const QList &folders); + bool save(); + + QString filePath() const {return m_file_path;} + QString backupPath() const {return m_backup_path;} + QString errorString() const {return m_error;} + ///true when the existing file was found unusable and an + ///empty document is used instead. Its copy is only made by + ///save(), right before the file is replaced, so repairing + ///the file instead of rebuilding it leaves no copy behind + bool isBroken() const {return m_broken;} + ///what exactly is wrong with that file (line and column of + ///the syntax error for instance), so the caller can tell the + ///user how to repair it instead of silently rebuilding it + QString brokenReason() const {return m_broken_reason;} + + private: + static bool parse(QIODevice &device, QDomDocument &document, QString *reason); + QDomElement categoryForPath(const QStringList &relative_path, bool create); + void createEmptyDocument(); + QString backupBrokenFile(); + static void scanRec(const QString &path, const QStringList &relative_path, QList &out); + static QDomElement directChildCategory(const QDomElement &parent, const QString &name); + static void collectOrphans(const QDomElement &parent, const QString &relative_path, const QSet &folders_key, QList &orphans); + static QString serialize(const QDomDocument &document); + + // attributes + private: + QString m_file_path; + QString m_backup_path; + QString m_error; + QString m_broken_reason; + QString m_snapshot; + bool m_file_exists = false; + bool m_force_save = false; + bool m_broken = false; + QDomDocument m_document; +}; + +#endif // QETLABELSFILE_H diff --git a/sources/autoNum/assignvariables.cpp b/sources/autoNum/assignvariables.cpp index 1cd48953e..3aa46b54c 100644 --- a/sources/autoNum/assignvariables.cpp +++ b/sources/autoNum/assignvariables.cpp @@ -24,6 +24,7 @@ #include "../qetgraphicsitem/element.h" #include "../qetxml.h" #include "../qetproject.h" +#include "../ElementsCollection/qetlabelsfile.h" #include #include #include @@ -728,74 +729,6 @@ namespace autonum return formula; } - /** - @brief prefixFromLabelFile - Look up a prefix for @a path (path[dirLevel] outermost, path[1] the - deepest directory; path[0], the element's own file name, is never - matched) in the qet_labels.xml at @a filepath. - - Descends through nested \ elements matching - path[dirLevel], path[dirLevel-1], ..., path[1] in turn, considering - only *direct* children at each step -- unlike a flat token scan, - this cannot be fooled by a same-named category living elsewhere in - the document at the wrong nesting depth (bugtracker #671 item 5). - - At each matched level, that category's own \ child -- even - an empty one -- overrides whatever a shallower ancestor already - provided, so an explicit empty \ cancels inheritance - rather than silently falling back to it (the behaviour requested in - PR #686 review). A category with no \ child at all leaves - the inherited value untouched, which is how a directory with no - prefix of its own comes to inherit its parent's, as the file's own - header comment documents. - - @return the prefix that applies, or a null QString if the file - cannot be read, is not well-formed, or does not describe this - path at all (as opposed to describing it with no prefix - anywhere along it, which is a non-null empty string). - */ - static QString prefixFromLabelFile(const QString &filepath, const QStringList &path, int dirLevel) - { - QFile file(filepath); - if (!file.open(QFile::ReadOnly | QFile::Text)) - return QString(); - - QDomDocument document; - if (!document.setContent(&file)) - return QString(); - - QDomElement node = document.documentElement(); - if (node.isNull()) - return QString(); - - QString prefix; - for (int i = dirLevel ; i >= 1 ; --i) { - QDomElement child = node.firstChildElement(QStringLiteral("category")); - while (!child.isNull() - && child.attribute(QStringLiteral("name")) != path[i]) { - child = child.nextSiblingElement(QStringLiteral("category")); - } - if (child.isNull()) - return QString(); - node = child; - - const QDomElement own = node.firstChildElement(QStringLiteral("prefix")); - if (!own.isNull()) { - //readElementText()'s null-vs-empty distinction that PR - //#686 needed for the old QXmlStreamReader-based lookup - //has a QDomElement equivalent: text() on an empty - //element can itself come back null depending on how the - //XML was written, so the same explicit fallback applies - //-- an empty QString here means "found, deliberately - //blank", not "not found". - prefix = own.text(); - if (prefix.isNull()) - prefix = QString(""); - } - } - return prefix; - } - /** @brief elementPrefixForLocation @param location @@ -854,7 +787,7 @@ namespace autonum { const QString common_file = QDir(QETApp::commonElementsDir()) .filePath(collection_root + QStringLiteral("/qet_labels.xml")); - const QString prefix = prefixFromLabelFile(common_file, path, dirLevel); + const QString prefix = QetLabelsFile::prefixForPath(common_file, path, dirLevel); if (!prefix.isNull()) { return prefix; } @@ -882,7 +815,7 @@ namespace autonum const QString candidate = QDir(dir).filePath(QStringLiteral("qet_labels.xml")); for (const QStringList &segments : {path_from_root, path}) { - const QString prefix = prefixFromLabelFile( + const QString prefix = QetLabelsFile::prefixForPath( candidate, segments, segments.size() - 1); if (!prefix.isNull()) { return prefix; diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index af63fba0e..206735c64 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -26,10 +26,14 @@ #include "../../qetmessagebox.h" #include "../../textgrid.h" #include "../../editor/terminalnamecheck.h" +#include "../../ElementsCollection/qetlabelsfile.h" +#include "../prefixconfigurationdialog.h" #include "../nokde/kcolorbutton.h" +#include #include #include #include +#include #include /** @@ -639,6 +643,90 @@ void GeneralConfigurationPage::on_m_user_macros_path_cb_currentIndexChanged(int } } +/** + @brief GeneralConfigurationPage::on_m_prefix_pb_clicked + Open the dialog where the prefixes of the user collection folders are + configured, creating the qet_labels.xml of that collection when it + does not exist yet. + Nothing is written until that dialog is validated : cancelling it + leaves the collection exactly as it was. +*/ +void GeneralConfigurationPage::on_m_prefix_pb_clicked() +{ + //The directory the page displays, even when the change has not + //been applied yet : QETApp::customElementsDir() still answers with + //the previously saved path, which is not what is shown when the + //combo has been put back on "Par defaut". + QString directory; + switch (ui->m_custom_elmt_path_cb->currentIndex()) { + case 1: //"Parcourir..." : the item itself holds the chosen path + directory = ui->m_custom_elmt_path_cb->itemData(1, Qt::DisplayRole).toString(); + break; + case 0: //"Par defaut" : where a default custom collection lives + directory = QETApp::dataDir() + QStringLiteral("/elements/"); + break; + default: + break; + } + if (directory.isEmpty()) { + directory = QETApp::customElementsDir(); + } + directory = QDir::cleanPath(directory); + + if (!QDir(directory).exists() && !QDir().mkpath(directory)) { + QMessageBox::warning(this, + tr("Répertoire introuvable"), + tr("Le répertoire de la collection utilisateur :\n%1\nn'existe pas et n'a pas pu être créé.") + .arg(directory)); + return; + } + + const QList folders = QetLabelsFile::scanFolders(directory); + if (folders.isEmpty()) { + QMessageBox::information(this, + tr("Aucun sous-dossier"), + tr("La collection utilisateur :\n%1\nne contient aucun sous-dossier : il n'y a donc aucun préfixe à configurer.") + .arg(directory)); + return; + } + + QetLabelsFile labels; + if (!labels.load(directory)) { + QMessageBox::warning(this, + tr("Fichier de préfixes illisible"), + labels.errorString()); + return; + } + if (labels.isBroken()) { + //A broken file may only be one forgotten tag away from being + //perfectly valid : tell what is wrong and let the user decide, + //rebuilding would drop every prefix the file still holds. + QMessageBox box(QMessageBox::Warning, + tr("Fichier de préfixes endommagé"), + tr("Le fichier %1 n'est pas un fichier XML valide :\n%2") + .arg(labels.filePath(), labels.brokenReason()), + QMessageBox::NoButton, + this); + box.addButton(tr("Corriger le fichier"), QMessageBox::AcceptRole); + auto *rebuild_button = box.addButton(tr("Reconstruire"), QMessageBox::DestructiveRole); + box.setInformativeText(tr("Rien n'a encore été modifié.\n\n" + "« Corriger le fichier » : cette fenêtre se ferme sans rien changer. " + "Ouvrez le fichier dans un éditeur de texte à l'endroit indiqué, " + "corrigez-le puis relancez cette commande.\n\n" + "« Reconstruire » : l'arborescence des dossiers est recréée, " + "mais tous les préfixes actuels sont perdus. Le fichier actuel " + "est conservé sous le nom qet_labels.xml.bak avant d'être remplacé.")); + box.setDetailedText(tr("Fichier : %1").arg(labels.filePath())); + box.exec(); + if (box.clickedButton() != rebuild_button) { + return; + } + } + + PrefixConfigurationDialog dialog(labels, folders, this); + dialog.exec(); +} + /** @brief GeneralConfigurationPage::on_m_material_list_browse_pb_clicked Let the user pick an existing material file. diff --git a/sources/ui/configpage/generalconfigurationpage.h b/sources/ui/configpage/generalconfigurationpage.h index 9a3702805..4954a6d3c 100644 --- a/sources/ui/configpage/generalconfigurationpage.h +++ b/sources/ui/configpage/generalconfigurationpage.h @@ -47,6 +47,7 @@ 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_prefix_pb_clicked(); void on_m_material_list_browse_pb_clicked(); void on_m_material_list_create_pb_clicked(); void on_m_indi_text_font_pb_clicked(); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index 50d46dd6a..2298006ee 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -361,6 +361,16 @@ + + + + Créer ou modifier le préfixe des dossiers de la collection utilisateur (fichier qet_labels.xml) + + + Configurer les préfixes… + + + diff --git a/sources/ui/prefixconfigurationdialog.cpp b/sources/ui/prefixconfigurationdialog.cpp new file mode 100644 index 000000000..37eee1608 --- /dev/null +++ b/sources/ui/prefixconfigurationdialog.cpp @@ -0,0 +1,254 @@ +/* + 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 "prefixconfigurationdialog.h" + +#include "../ElementsCollection/qetlabelsfile.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + @brief PrefixConfigurationDialog::PrefixConfigurationDialog + @param labels : the loaded labels file the dialog edits + @param folders : every directory of the collection, as returned by + QetLabelsFile::scanFolders() + @param parent +*/ +PrefixConfigurationDialog::PrefixConfigurationDialog(QetLabelsFile &labels, const QList &folders, QWidget *parent) : + QDialog(parent), + m_labels(labels), + m_folders(folders), + m_tree(new QTreeWidget(this)) +{ + setWindowTitle(tr("Préfixes de la collection utilisateur", "title of the dialog configuring the prefixes of the user collection")); + setModal(true); + resize(700, 500); + + auto *layout = new QVBoxLayout(this); + + auto *hint = new QLabel(this); + hint->setWordWrap(true); + hint->setText(tr("Chaque dossier de la collection possède un préfixe : il est ajouté devant l'étiquette des éléments du dossier.\n" + "Un champ vide signifie que le dossier reprend le préfixe de son dossier parent.")); + layout->addWidget(hint); + + m_tree->setColumnCount(2); + m_tree->setHeaderLabels(QStringList() << tr("Dossier", "column header of the folder tree") << tr("Préfixe", "column header of the prefix column")); + m_tree->setRootIsDecorated(true); + m_tree->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_tree->setSelectionMode(QAbstractItemView::NoSelection); + m_tree->header()->setSectionResizeMode(0, QHeaderView::Stretch); + m_tree->header()->setSectionResizeMode(1, QHeaderView::ResizeToContents); + layout->addWidget(m_tree); + + auto *expand_pb = new QPushButton(tr("Tout déplier"), this); + auto *collapse_pb = new QPushButton(tr("Tout replier"), this); + connect(expand_pb, &QPushButton::clicked, m_tree, &QTreeWidget::expandAll); + connect(collapse_pb, &QPushButton::clicked, m_tree, &QTreeWidget::collapseAll); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttons, &QDialogButtonBox::accepted, this, &PrefixConfigurationDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &PrefixConfigurationDialog::reject); + + auto *bottom_layout = new QHBoxLayout(); + bottom_layout->addWidget(expand_pb); + bottom_layout->addWidget(collapse_pb); + bottom_layout->addStretch(); + bottom_layout->addWidget(buttons); + layout->addLayout(bottom_layout); + + //Asking before the tree is shown keeps the answer from appearing + //in the middle of a dialog the user has not seen yet. + askWhatToDoWithOrphans(); + buildTree(); +} + +PrefixConfigurationDialog::~PrefixConfigurationDialog() +{ +} + +/** + @brief PrefixConfigurationDialog::askWhatToDoWithOrphans + When the labels file describes directories the collection does not + have (deleted or renamed folders, or entries written by hand in an + other layout), the user chooses whether they are kept or dropped. + The choice is only applied by accept(). +*/ +void PrefixConfigurationDialog::askWhatToDoWithOrphans() +{ + const QStringList orphans = m_labels.orphanPaths(m_folders); + if (orphans.isEmpty()) { + return; + } + + QMessageBox box(QMessageBox::Question, + tr("Entrées sans dossier"), + tr("%n entrée(s) de qet_labels.xml ne correspond à aucun dossier de la collection :\n" + "les conserver ou les supprimer ?", nullptr, static_cast(orphans.size())), + QMessageBox::NoButton, + parentWidget()); + auto *keep_button = box.addButton(tr("Conserver"), QMessageBox::AcceptRole); + box.addButton(tr("Supprimer"), QMessageBox::DestructiveRole); + box.setDetailedText(orphans.join(QLatin1Char('\n'))); + box.exec(); + + //Closing the box without choosing keeps the entries : deleting + //data must always be an explicit decision. + m_remove_orphans = box.clickedButton() != nullptr + && box.clickedButton() != keep_button; +} + +/** + @brief PrefixConfigurationDialog::buildTree + One item per directory of the collection, nested the way the + directories are, each with a line edit holding the prefix already + stored for that directory. +*/ +void PrefixConfigurationDialog::buildTree() +{ + m_tree->setUpdatesEnabled(false); + + QHash known; + for (const QStringList &folder : m_folders) { + QTreeWidgetItem *parent_item = nullptr; + QString built; + for (const QString &name : folder) { + built = built.isEmpty() ? name : built + QLatin1Char('/') + name; + + QTreeWidgetItem *item = known.value(built, nullptr); + if (item == nullptr) { + item = parent_item == nullptr + ? new QTreeWidgetItem(m_tree) + : new QTreeWidgetItem(parent_item); + item->setText(0, name); + item->setFlags(item->flags() & ~Qt::ItemIsEditable); + known.insert(built, item); + } + parent_item = item; + } + if (parent_item == nullptr) { + continue; + } + parent_item->setData(0, Qt::UserRole, folder); + //What the file holds for that folder, kept to tell a row the + //user touched from one they left alone (@see accept()) + parent_item->setData(1, Qt::UserRole, m_labels.prefix(folder).trimmed()); + + auto *edit = new QLineEdit(m_labels.prefix(folder).trimmed(), m_tree); + edit->setClearButtonEnabled(true); + //A folder whose file holds an explicit shows an empty + //field too, but does not inherit : say so instead of promising + //an inheritance that will not happen + edit->setPlaceholderText(m_labels.hasPrefix(folder) + ? tr("aucun préfixe : n'hérite pas du parent", + "placeholder of an empty prefix field whose folder explicitly has no prefix, which cancels the inheritance") + : tr("hériter du dossier parent", "placeholder of an empty prefix field")); + connect(edit, &QLineEdit::textEdited, this, [parent_item, edit]() { + //Once the user has typed in the field, whatever it holds + //when OK is pressed is what the folder gets - an emptied + //field then means "inherit" again, even when the file had + //an explicit + parent_item->setData(1, Qt::UserRole + 1, true); + edit->setPlaceholderText(tr("hériter du dossier parent", "placeholder of an empty prefix field")); + }); + edit->installEventFilter(this); + m_tree->setItemWidget(parent_item, 1, edit); + } + + m_tree->expandAll(); + m_tree->setUpdatesEnabled(true); +} + +/** + @brief PrefixConfigurationDialog::accept + Apply every prefix shown in the dialog, add the missing categories, + optionally drop the entries the user agreed to delete, then write the + file. When the write fails the dialog stays open so nothing typed in + it is lost. +*/ +void PrefixConfigurationDialog::accept() +{ + m_labels.ensureStructure(m_folders); + + for (QTreeWidgetItemIterator iterator(m_tree) ; *iterator ; ++iterator) { + QTreeWidgetItem *item = *iterator; + auto *edit = qobject_cast(m_tree->itemWidget(item, 1)); + if (edit == nullptr) { + continue; + } + const QString text = edit->text().trimmed(); + //A row the user did not touch is left exactly as the file has + //it. Writing every field back would turn an explicit + //, which shows empty and cancels the inheritance, + //into no at all, and that folder would silently start + //inheriting its parent's prefix again. + if (!item->data(1, Qt::UserRole + 1).toBool() + && text == item->data(1, Qt::UserRole).toString()) { + continue; + } + m_labels.setPrefix(item->data(0, Qt::UserRole).toStringList(), text); + } + + if (m_remove_orphans) { + m_labels.removeOrphans(m_folders); + } + + if (!m_labels.save()) { + QMessageBox::critical(this, + tr("Enregistrement impossible"), + tr("Le fichier %1 n'a pas pu être enregistré :\n%2") + .arg(m_labels.filePath(), m_labels.errorString())); + return; + } + + if (!m_labels.backupPath().isEmpty()) { + QMessageBox::information(this, + tr("Fichier endommagé remplacé"), + tr("Le fichier %1 était illisible : il a été remplacé.\nSa copie a été conservée sous :\n%2") + .arg(m_labels.filePath(), m_labels.backupPath())); + } + + QDialog::accept(); +} + +/** + @brief PrefixConfigurationDialog::eventFilter + Return and Enter are swallowed while a prefix field has the focus : + otherwise the event would reach the dialog and immediately validate + it, closing a dialog the user is still filling in. +*/ +bool PrefixConfigurationDialog::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && qobject_cast(watched) != nullptr) { + const int key = static_cast(event)->key(); + if (key == Qt::Key_Return || key == Qt::Key_Enter) { + return true; + } + } + return QDialog::eventFilter(watched, event); +} diff --git a/sources/ui/prefixconfigurationdialog.h b/sources/ui/prefixconfigurationdialog.h new file mode 100644 index 000000000..f16d3be8a --- /dev/null +++ b/sources/ui/prefixconfigurationdialog.h @@ -0,0 +1,65 @@ +/* + 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 PREFIXCONFIGURATIONDIALOG_H +#define PREFIXCONFIGURATIONDIALOG_H + +#include +#include +#include + +class QetLabelsFile; +class QTreeWidget; + +/** + @brief The PrefixConfigurationDialog class + + Shows the whole directory tree of the user elements collection, one + text field per directory, filled with the prefix that directory + already has in qet_labels.xml (empty when it has none, which means it + inherits the prefix of its parent directory). + + Nothing is written while the dialog is open : validating calls + QetLabelsFile::save(), cancelling leaves the file exactly as it was. +*/ +class PrefixConfigurationDialog : public QDialog +{ + Q_OBJECT + + public: + explicit PrefixConfigurationDialog(QetLabelsFile &labels, const QList &folders, QWidget *parent = nullptr); + ~PrefixConfigurationDialog() override; + + public slots: + void accept() override; + + private: + void askWhatToDoWithOrphans(); + void buildTree(); + + protected: + bool eventFilter(QObject *watched, QEvent *event) override; + + // attributes + private: + QetLabelsFile &m_labels; + QList m_folders; + QTreeWidget *m_tree; + bool m_remove_orphans = false; +}; + +#endif // PREFIXCONFIGURATIONDIALOG_H