diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index fb2d94869..b8c2278ea 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -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; diff --git a/sources/container/qetcontainer.cpp b/sources/container/qetcontainer.cpp index 0b8d430d2..631bf0bed 100644 --- a/sources/container/qetcontainer.cpp +++ b/sources/container/qetcontainer.cpp @@ -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 entries; + return QetZip::fromBytes(content, &entries, error) + && join(entries, document, error); +} + /** @brief QetContainer::split @param project : the project document, as QETProject::toXml() makes it diff --git a/sources/container/qetcontainer.h b/sources/container/qetcontainer.h index 5143ab220..686818877 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -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 diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index 52c7d5b87..c348454db 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -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"; } } diff --git a/sources/projectview.cpp b/sources/projectview.cpp index 465699ce4..bcfd1294b 100644 --- a/sources/projectview.cpp +++ b/sources/projectview.cpp @@ -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 diff --git a/sources/qetapp.cpp b/sources/qetapp.cpp index 9fddcead5..5cb926807 100644 --- a/sources/qetapp.cpp +++ b/sources/qetapp.cpp @@ -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("^\\.")); } diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index 02538c4dd..68eab6349 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -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); diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 829ffd3b6..8336d3836 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -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 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 diff --git a/sources/qetproject.h b/sources/qetproject.h index 212d08169..06b4847bd 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -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(); diff --git a/sources/scripting/liveserver.cpp b/sources/scripting/liveserver.cpp index db241068e..132b244f8 100644 --- a/sources/scripting/liveserver.cpp +++ b/sources/scripting/liveserver.cpp @@ -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() diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index eec89c0e5..12137be7f 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -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; } diff --git a/sources/ui/configpage/projectconfigpages.cpp b/sources/ui/configpage/projectconfigpages.cpp index 4e1c62964..9623f4985 100644 --- a/sources/ui/configpage/projectconfigpages.cpp +++ b/sources/ui/configpage/projectconfigpages.cpp @@ -15,6 +15,7 @@ You should have received a copy of the GNU General Public License along with QElectroTech. If not, see . */ +#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())); diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 4cfe857cd..b124a1854 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -4,13 +4,16 @@ #include #include +#include + #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 children(const QDomNode &node) { QList 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 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 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 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() { diff --git a/tests/qttest/tst_resaveunchanged.cpp b/tests/qttest/tst_resaveunchanged.cpp index f372f99d7..46178c692 100644 --- a/tests/qttest/tst_resaveunchanged.cpp +++ b/tests/qttest/tst_resaveunchanged.cpp @@ -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("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*]*name=\"saved(filename|filepath|filenamedir|date|date-us|date-eu|time)\"[^>]*>[^<]*")); + 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()