Open and save projects as .qetz (zipped, opt-in)

Third stage of the zipped project file (#1440). A project is saved as a
.qetz when its file name ends in .qetz -- Save As offers it as a second
file type, and --resave, --set-titleblock, the script API's save() and
the live-mode snapshot follow the name too. Saving a .qet project still
writes a .qet; nothing changes unless the user picks .qetz.

Opening recognises a .qetz by its content (a zip), not its name, and
reads it as bytes: text mode would alter it on Windows. The project
document is joined back and read by the same code as a .qet. Crash
backups stay plain XML. Drag and drop, the open dialog and the
numbering import accept .qetz; the database export names its file from
the project's name without assuming ".qet".

Parts are now written without added indentation (one line each): the
symbol definitions are kept as read, whitespace included, and parts
that gained indentation made the next save differ (five forum projects).

Tests: tst_resaveunchanged::containerSavesTheSameProject (four examples
saved as .qetz, then as .qet, equal the straight .qet; pictures checked
to fail without the fix), tst_qetcontainer::noWhitespaceAdded. Outside
ctest: 341 of 341 projects QET can open (examples, bugtracker, GitHub,
forum) give the same .qet through a .qetz; 181.6 MB of .qet became
36.9 MB of .qetz.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Wga9B92BNurDEtxxtCDxf
This commit is contained in:
ispyisail
2026-10-10 23:11:24 +13:00
parent a475383915
commit fac09253a0
14 changed files with 215 additions and 44 deletions
+24 -7
View File
@@ -870,6 +870,15 @@ int exportLinks(QETProject &project, const QString &output)
int resaveProject(QETProject &project, const QString &output)
{
const QDomDocument doc = project.toXml();
if (QETProject::isContainerPath(output)) {
QString error;
if (!QETProject::writeContainer(doc, output, &error)) {
err << "Cannot write '" << output << "': " << error << "\n";
return 1;
}
out << "Re-saved project -> " << output << "\n";
return 0;
}
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
@@ -955,14 +964,22 @@ int setTitleBlock(QETProject &project, const QString &output,
}
const QDomDocument doc = project.toXml();
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
if (QETProject::isContainerPath(output)) {
QString error;
if (!QETProject::writeContainer(doc, output, &error)) {
err << "Cannot write '" << output << "': " << error << "\n";
return 1;
}
} else {
QFile file(output);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
err << "Cannot open '" << output << "' for writing.\n";
return 1;
}
QTextStream fout(&file);
fout << doc.toString(4);
file.close();
}
QTextStream fout(&file);
fout << doc.toString(4);
file.close();
out << "Stamped " << fields.size() << " field(s) on "
<< folios << " folio(s) -> " << output << "\n";
return 0;
+29 -2
View File
@@ -34,7 +34,10 @@ void setError(QString *error, const QString &text)
if (error) *error = text;
}
//An element on its own, as a .qet is written (QET::writeXmlFile())
//An element on its own, written without adding indentation: the
//whitespace text already in the document (QETProject keeps the symbol
//definitions as they were read, spacing included) must come back as it
//was, or the next save writes it differently. Parts are one line.
QByteArray serialize(const QDomNode &node)
{
QDomDocument document;
@@ -42,7 +45,7 @@ QByteArray serialize(const QDomNode &node)
QStringLiteral("xml"),
QStringLiteral("version=\"1.0\" encoding=\"UTF-8\"")));
document.appendChild(document.importNode(node, true));
return document.toString(4).toUtf8();
return document.toString(-1).toUtf8();
}
//A file name from a name chosen by the user: no folders, nothing a zip
@@ -136,6 +139,30 @@ bool QetContainer::parse(const QByteArray &xml, QDomDocument *document, QString
#endif
}
/**
@brief QetContainer::isZip
A .qetz is recognised by what it holds, not its name: a crash backup
of a .qetz project is plain XML, and a renamed file is still a zip.
*/
bool QetContainer::isZip(const QByteArray &content)
{
return content.startsWith("PK\x03\x04");
}
/**
@brief QetContainer::readDocument
The project document held in @p content: unzipped and joined for a
.qetz, parsed for a .qet.
*/
bool QetContainer::readDocument(const QByteArray &content, QDomDocument *document, QString *error)
{
if (!isZip(content))
return parse(content, document, error);
QList<QetZip::Entry> entries;
return QetZip::fromBytes(content, &entries, error)
&& join(entries, document, error);
}
/**
@brief QetContainer::split
@param project : the project document, as QETProject::toXml() makes it
+4
View File
@@ -54,6 +54,10 @@ namespace QetContainer
/// Parse @p xml as a .qet is read (QETProject::openFile)
bool parse(const QByteArray &xml, QDomDocument *document, QString *error = nullptr);
/// true if @p content is a zip, so a .qetz rather than a .qet
bool isZip(const QByteArray &content);
/// The project document in @p content, a .qet or a .qetz
bool readDocument(const QByteArray &content, QDomDocument *document, QString *error = nullptr);
}
#endif // QETCONTAINER_H
+2 -2
View File
@@ -2669,8 +2669,8 @@ void projectDataBase::exportDb(projectDataBase *db,
if (dir_.isEmpty()) {
dir_ = QETApp::documentDir() % "/" % tr("untitled") % ".sqlite";
} else {
dir_.remove(".qet");
dir_.append(".sqlite");
const QFileInfo project_file(dir_);
dir_ = project_file.path() % "/" % project_file.completeBaseName() % ".sqlite";
}
}
+15 -4
View File
@@ -339,11 +339,16 @@ int ProjectView::tryClosingDiagrams()
*/
QString ProjectView::askUserForFilePath(bool assign) {
// ask the user for a filepath in order to save the project
const QString qet_filter = tr("Project QElectroTech (*.qet)", "filetypes allowed when saving a project file");
const QString qetz_filter = tr("Zipped project QElectroTech (*.qetz)", "filetypes allowed when saving a project file");
QString selected_filter = QETProject::isContainerPath(m_project->filePath()) ? qetz_filter : qet_filter;
QString filepath = QFileDialog::getSaveFileName(
this,
tr("Save as", "dialog title"),
m_project -> currentDir() + "/" + tr("untitled") + ".qet",
tr("Project QElectroTech (*.qet)", "filetypes allowed when saving a project file")
m_project -> currentDir() + "/" + tr("untitled")
+ (selected_filter == qetz_filter ? ".qetz" : ".qet"),
qet_filter + ";;" + qetz_filter,
&selected_filter
);
// if no filepath is provided, return an empty string
@@ -359,9 +364,15 @@ QString ProjectView::askUserForFilePath(bool assign) {
// extension at all whenever the portal didn't (bugtracker #270).
// Stripping any existing suffix first and re-appending it once is
// correct either way.
if (filepath.endsWith(".qet", Qt::CaseInsensitive))
// A .qetz is kept as typed, or chosen through its filter.
const bool zipped = filepath.endsWith(".qetz", Qt::CaseInsensitive)
|| (selected_filter == qetz_filter
&& !filepath.endsWith(".qet", Qt::CaseInsensitive));
if (filepath.endsWith(".qetz", Qt::CaseInsensitive))
filepath.chop(5);
else if (filepath.endsWith(".qet", Qt::CaseInsensitive))
filepath.chop(4);
filepath += ".qet";
filepath += zipped ? ".qetz" : ".qet";
if (assign) {
// assign the provided filepath to the currently edited project
+1
View File
@@ -1179,6 +1179,7 @@ QStringList QETApp::handledFileExtensions()
static QStringList ext;
if (!ext.count()) {
ext << "qet";
ext << "qetz";
ext << "elmt";
ext << QString(TITLEBLOCKS_FILE_EXTENSION).remove(QRegularExpression("^\\."));
}
+1 -1
View File
@@ -1745,7 +1745,7 @@ bool QETDiagramEditor::openProject()
this,
tr("Open a file"),
open_dialog_dir.absolutePath(),
tr("QElectroTech project(*.qet);;XML Files (*.xml);;All files (*)")
tr("QElectroTech project(*.qet *.qetz);;XML Files (*.xml);;All files (*)")
);
if (filepath.isEmpty()) return(false);
+50 -10
View File
@@ -17,6 +17,7 @@
*/
#include "qetproject.h"
#include "container/qetcontainer.h"
#include "ElementsCollection/xmlelementcollection.h"
#include "autoNum/assignvariables.h"
@@ -385,10 +386,20 @@ QETProject::ProjectState QETProject::openFile(QFile *file)
load_timer.start();
bool opened_here = file->isOpen() ? false : true;
if (!file->isOpen()
&& !file->open(QIODevice::ReadOnly
| QIODevice::Text)) {
return FileOpenFailed;
if (opened_here)
{
//A .qetz is a zip and is read as bytes: text mode would turn
//its CR LF pairs into LF on Windows. A .qet is read as text,
//as it always was.
if (!file->open(QIODevice::ReadOnly)) {
return FileOpenFailed;
}
if (!QetContainer::isZip(file->peek(4))) {
file->close();
if (!file->open(QIODevice::ReadOnly | QIODevice::Text)) {
return FileOpenFailed;
}
}
}
QFileInfo fi(*file);
setFilePath(fi.absoluteFilePath());
@@ -410,12 +421,12 @@ QETProject::ProjectState QETProject::openFile(QFile *file)
// call .text() on themselves -- which is exactly where the bug was.
// The option exists since Qt 6.5; older Qt always drops such nodes,
// so there an all-whitespace value still reloads as "".
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
if (!xml_project.setContent(content, QDomDocument::ParseOption::PreserveSpacingOnlyNodes))
#else
if (!xml_project.setContent(content))
#endif
// QetContainer::parse() does exactly that, for a .qet and for each part
// of a .qetz alike.
QString read_error;
if (!QetContainer::readDocument(content, &xml_project, &read_error))
{
qWarning().noquote() << "Unable to read" << fi.fileName() << ":" << read_error;
if(opened_here) {
file->close();
}
@@ -1617,6 +1628,30 @@ bool QETProject::close()
@see setFilePath()
@return true if the project was successfully saved, else false
*/
/**
@brief QETProject::isContainerPath
@return true if @p path names a .qetz file: write() saves it zipped
*/
bool QETProject::isContainerPath(const QString &path)
{
return path.endsWith(QLatin1String(".qetz"), Qt::CaseInsensitive);
}
/**
@brief QETProject::writeContainer
Write the project document @p project to @p path as a .qetz: the same
document, in parts, zipped (#1440). The file is replaced only once
the whole archive is written.
*/
bool QETProject::writeContainer(const QDomDocument &project, const QString &path, QString *error)
{
const QList<QetZip::Entry> entries = QetContainer::split(
project,
QStringLiteral("QElectroTech %1").arg(QetVersion::currentVersion().toString()),
error);
return !entries.isEmpty() && QetZip::write(path, entries, error);
}
QETResult QETProject::write()
{
// this operation requires a filepath
@@ -1639,7 +1674,12 @@ QETResult QETProject::write()
QDomDocument xml_project(toXml());
QString error_message;
if (!QET::writeXmlFile(xml_project, m_file_path, &error_message))
if (isContainerPath(m_file_path))
{
if (!writeContainer(xml_project, m_file_path, &error_message))
return(error_message);
}
else if (!QET::writeXmlFile(xml_project, m_file_path, &error_message))
return(error_message);
// The project has just been written to a writable file (e.g. saved to
+5
View File
@@ -345,6 +345,11 @@ class QETProject : public QObject
void writeBackup();
void init();
ProjectState openFile(QFile *file);
public:
static bool isContainerPath(const QString &path);
static bool writeContainer(const QDomDocument &project, const QString &path,
QString *error = nullptr);
private:
static QUuid derivedUuid(const QByteArray &content);
void refresh();
+3 -1
View File
@@ -921,7 +921,9 @@ QJsonObject LiveServer::snapshot(const QString &raw_path)
if (info.exists()) return failure(QStringLiteral("%1 already exists").arg(raw_path));
QDomDocument xml(project->toXml());
QString error;
if (!QET::writeXmlFile(xml, info.absoluteFilePath(), &error))
if (QETProject::isContainerPath(info.absoluteFilePath())
? !QETProject::writeContainer(xml, info.absoluteFilePath(), &error)
: !QET::writeXmlFile(xml, info.absoluteFilePath(), &error))
return failure(QStringLiteral("snapshot not written: %1").arg(error));
return {{QStringLiteral("ok"), true}, {QStringLiteral("path"), info.absoluteFilePath()},
{QStringLiteral("folio"), e->currentDiagramView()
+3 -1
View File
@@ -408,7 +408,9 @@ bool QetScriptApi::save(const QString &output)
QDomDocument xml_doc(m_project->toXml());
QString error_message;
if (!QET::writeXmlFile(xml_doc, output, &error_message)) {
if (QETProject::isContainerPath(output)
? !QETProject::writeContainer(xml_doc, output, &error_message)
: !QET::writeXmlFile(xml_doc, output, &error_message)) {
log(QStringLiteral("qet.save: %1").arg(error_message));
return false;
}
+3 -2
View File
@@ -15,6 +15,7 @@
You should have received a copy of the GNU General Public License
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#include "../../container/qetcontainer.h"
#include "projectconfigpages.h"
#include "../autoNum/autonumschemecommand.h"
@@ -1054,7 +1055,7 @@ void ProjectAutoNumConfigPage::importFromProject()
this,
tr("Import numbering from a project"),
m_project->currentDir(),
tr("Project QElectroTech (*.qet)"));
tr("Project QElectroTech (*.qet *.qetz)"));
if (path.isEmpty()) {
return;
}
@@ -1067,7 +1068,7 @@ void ProjectAutoNumConfigPage::importFromProject()
}
QDomDocument doc;
if (!doc.setContent(&file)) {
if (!QetContainer::readDocument(file.readAll(), &doc)) {
QMessageBox::warning(this, tr("Import not possible"),
tr("%1 is not a valid QElectroTech project.")
.arg(QFileInfo(path).fileName()));
+38 -12
View File
@@ -4,13 +4,16 @@
#include <QDir>
#include <QFile>
#include <functional>
#include "container/qetcontainer.h"
// A project document split into the parts of a .qetz, zipped, unzipped and
// joined back is the document it was: every example, and every .qet in the
// folder QET_CONTAINER_CORPUS names, if set. Whitespace-only text between
// elements is indentation and is not compared; elsewhere it is (a
// title-block value of " ", #973).
// folder QET_CONTAINER_CORPUS names, if set -- exactly, whitespace text
// included: QETProject writes the symbol definitions back as it read them,
// spacing and all, so a part that came back re-indented would change the
// next save.
class tst_qetcontainer : public QObject
{
Q_OBJECT
@@ -18,16 +21,9 @@ class tst_qetcontainer : public QObject
static QList<QDomNode> children(const QDomNode &node)
{
QList<QDomNode> all;
bool has_element = false;
for (QDomNode c = node.firstChild() ; !c.isNull() ; c = c.nextSibling()) {
for (QDomNode c = node.firstChild() ; !c.isNull() ; c = c.nextSibling())
all << c;
has_element |= c.isElement();
}
if (!has_element) return all;
QList<QDomNode> kept;
for (const QDomNode &c : all)
if (!(c.isText() && c.nodeValue().trimmed().isEmpty())) kept << c;
return kept;
return all;
}
// empty when equal, else where they differ
@@ -106,6 +102,36 @@ private slots:
QVERIFY2(d.isEmpty(), qPrintable(d));
}
// Splitting and joining adds no whitespace text. QETProject keeps the
// symbol definitions as it read them and writes them back as they
// are, so indentation a part gained would change the next save
// (forum attachments 1328, 1978, 2057, 3003, 3004 did, with indented
// parts). Here: a document without any whitespace-only text, as
// QETProject::toXml() builds one, must come back without any.
void noWhitespaceAdded()
{
QDomDocument original;
QVERIFY(original.setContent(read(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"))));
auto whitespace_nodes = [](const QDomDocument &d) {
int n = 0;
std::function<void(const QDomNode &)> walk = [&](const QDomNode &node) {
for (QDomNode c = node.firstChild() ; !c.isNull() ; c = c.nextSibling()) {
if (c.isText() && c.nodeValue().trimmed().isEmpty()) ++n;
walk(c);
}
};
walk(d);
return n;
};
QCOMPARE(whitespace_nodes(original), 0);
QString error;
const QList<QetZip::Entry> entries = QetContainer::split(original, QString(), &error);
QVERIFY2(!entries.isEmpty(), qPrintable(error));
QDomDocument back;
QVERIFY2(QetContainer::join(entries, &back, &error), qPrintable(error));
QCOMPARE(whitespace_nodes(back), 0);
}
// The folio pictures leave the folios, once each
void picturesAreFiles()
{
+37 -2
View File
@@ -30,9 +30,12 @@ class tst_resaveunchanged : public QObject
// --resave @p in to a new file, in a sandbox of its own (so a running
// QElectroTech cannot answer instead); returns the new file's path.
QString resave(const QString &in)
QString resave(const QString &in, const QString &suffix = QStringLiteral(".qet"))
{
const QString out = m_dir.filePath(QStringLiteral("out%1.qet").arg(m_run));
//Always p.qet, in its own folder: an untitled project takes its
//file name as title, so two names would make two projects.
QDir().mkpath(m_dir.filePath(QStringLiteral("run%1").arg(m_run)));
const QString out = m_dir.filePath(QStringLiteral("run%1/p").arg(m_run) + suffix);
const QString home = m_dir.filePath(QStringLiteral("home%1").arg(m_run++));
QDir().mkpath(home);
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
@@ -282,6 +285,38 @@ private slots:
QVERIFY2(read(saved).contains(b), "the arrow without a terminal was not kept");
}
// Saved as a .qetz (the zipped project, #1440) and that saved again as a
// .qet, a project is what it is saved straight as a .qet: the same
// file but for when and where it was saved. The examples with pictures
// (weneedpolonez), tables and links (industrial) and several title
// blocks (m_000).
void containerSavesTheSameProject_data()
{
QTest::addColumn<QString>("project");
for (const char *name : {"Projet_vierge.qet", "industrial.qet", "m_000.qet",
"weneedpolonez-Polonez_MR89_wiring_diagram.qet"})
QTest::newRow(name) << QStringLiteral(QET_EXAMPLES_DIR "/") + QLatin1String(name);
}
void containerSavesTheSameProject()
{
QFETCH(QString, project);
const QString straight = resave(project);
QVERIFY2(!straight.isEmpty(), "--resave as .qet failed");
const QString zipped = resave(project, QStringLiteral(".qetz"));
QVERIFY2(!zipped.isEmpty(), "--resave as .qetz failed");
QVERIFY2(read(zipped).startsWith("PK\x03\x04"), "the .qetz is not a zip");
QVERIFY2(read(zipped).size() < read(straight).size() / 2, "the .qetz is not smaller");
const QString back = resave(zipped);
QVERIFY2(!back.isEmpty(), "opening the .qetz failed");
auto without_save_stamps = [](QByteArray xml) {
static const QRegularExpression stamp(QStringLiteral(
"\\s*<property [^>]*name=\"saved(filename|filepath|filenamedir|date|date-us|date-eu|time)\"[^>]*>[^<]*</property>"));
return QString::fromUtf8(xml).remove(stamp);
};
QCOMPARE(without_save_stamps(read(back)), without_save_stamps(read(straight)));
}
// A title-block value that is a single space is kept through two saves
// (#973), and a value with accents comes back as it went in.
void singleSpaceValueKept()