Merge pull request #1168 from Kellermorph/prefix-button

Settings: configure the folder prefixes of the user collection
This commit is contained in:
Laurent Trinques
2026-09-30 16:07:18 +02:00
committed by GitHub
9 changed files with 1195 additions and 70 deletions
+4
View File
@@ -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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "qetlabelsfile.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSaveFile>
/**
@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<QStringList> QetLabelsFile::scanFolders(const QString &collection_dir)
{
QList<QStringList> 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<QStringList> &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 \<category name="..."\> 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 \<prefix\> child -- even
an empty one -- overrides whatever a shallower ancestor already
provided, so an explicit empty \<prefix/\> cancels inheritance
rather than silently falling back to it (the behaviour requested in
PR #686 review). A category with no \<prefix\> 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 \<labels\> 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 \<labels\> 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 <labels>.")
.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 \<labels\> 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 \<prefix\> child of its own,
even an empty one. prefix() alone cannot tell that apart from a
category without any \<prefix\> : both give it no value, while an
empty \<prefix/\> 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 \<category\> 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 \<category\> elements are
created on the way down
@return the \<category\> 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 \<category\> 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 \<prefix\> child, which is exactly "inherit the parent's
prefix".
@param folders : as returned by scanFolders()
*/
void QetLabelsFile::ensureStructure(const QList<QStringList> &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 \<prefix\> 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 \<category\> 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<QString> &folders_key, QList<QDomElement> &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 \<category\> 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<QStringList> &folders) const
{
QSet<QString> folders_key;
for (const QStringList &folder : folders) {
folders_key.insert(folder.join(QLatin1Char('/')));
}
QList<QDomElement> 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<QStringList> &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. "<?xml " (with the trailing space)
//tells the real declaration from any <?xml-stylesheet ...?>.
const int declaration_end = content.indexOf(QLatin1String("?>"));
if (content.startsWith(QLatin1String("<?xml ")) && declaration_end > 0) {
content.remove(0, declaration_end + 2);
}
while (content.startsWith(QLatin1Char('\n'))) {
content.remove(0, 1);
}
return QStringLiteral("<?xml version=\"1.0\" encoding=\"utf-8\"?>\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;
}
+107
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef QETLABELSFILE_H
#define QETLABELSFILE_H
#include <QCoreApplication>
#include <QDomDocument>
#include <QList>
#include <QSet>
#include <QString>
#include <QStringList>
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<QStringList> 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<QStringList> &folders) const;
void ensureStructure(const QList<QStringList> &folders);
void setPrefix(const QStringList &relative_path, const QString &prefix);
int removeOrphans(const QList<QStringList> &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<QStringList> &out);
static QDomElement directChildCategory(const QDomElement &parent, const QString &name);
static void collectOrphans(const QDomElement &parent, const QString &relative_path, const QSet<QString> &folders_key, QList<QDomElement> &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
+3 -70
View File
@@ -24,6 +24,7 @@
#include "../qetgraphicsitem/element.h"
#include "../qetxml.h"
#include "../qetproject.h"
#include "../ElementsCollection/qetlabelsfile.h"
#include <QDir>
#include <QDomDocument>
#include <QStringList>
@@ -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 \<category name="..."\> 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 \<prefix\> child -- even
an empty one -- overrides whatever a shallower ancestor already
provided, so an explicit empty \<prefix/\> cancels inheritance
rather than silently falling back to it (the behaviour requested in
PR #686 review). A category with no \<prefix\> 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;
@@ -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 <QDir>
#include <QFileDialog>
#include <QFileInfo>
#include <QFontDialog>
#include <QMessageBox>
#include <QSettings>
/**
@@ -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<QStringList> 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.
@@ -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();
@@ -361,6 +361,16 @@
</item>
</widget>
</item>
<item row="4" column="2">
<widget class="QPushButton" name="m_prefix_pb">
<property name="toolTip">
<string extracomment="tooltip of the button opening the prefix configuration dialog of the user collection">Créer ou modifier le préfixe des dossiers de la collection utilisateur (fichier qet_labels.xml)</string>
</property>
<property name="text">
<string extracomment="button opening the dialog where the folder prefixes of the user collection are configured">Configurer les préfixes…</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_7">
<property name="text">
+254
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "prefixconfigurationdialog.h"
#include "../ElementsCollection/qetlabelsfile.h"
#include <QDialogButtonBox>
#include <QHash>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QKeyEvent>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QTreeWidget>
#include <QTreeWidgetItem>
#include <QTreeWidgetItemIterator>
/**
@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<QStringList> &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<int>(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<QString, QTreeWidgetItem *> 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 <prefix/> 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 <prefix/>
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<QLineEdit *>(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
//<prefix/>, which shows empty and cancels the inheritance,
//into no <prefix> 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<QLineEdit *>(watched) != nullptr) {
const int key = static_cast<QKeyEvent *>(event)->key();
if (key == Qt::Key_Return || key == Qt::Key_Enter) {
return true;
}
}
return QDialog::eventFilter(watched, event);
}
+65
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef PREFIXCONFIGURATIONDIALOG_H
#define PREFIXCONFIGURATIONDIALOG_H
#include <QDialog>
#include <QList>
#include <QStringList>
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<QStringList> &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<QStringList> m_folders;
QTreeWidget *m_tree;
bool m_remove_orphans = false;
};
#endif // PREFIXCONFIGURATIONDIALOG_H