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()));