From 7e96bb51d23e8bee9ecff2c8d865b32bb981cba5 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 08:23:31 +1300 Subject: [PATCH 1/7] Sync the folder after saving, and save from the command line through a temporary file QSaveFile writes a new file, forces it to disk and renames it over the old one, but does not force the folder to disk. On ext4 or XFS a power cut soon after a save can then lose the rename: the previous file comes back, or none. QET::syncFolderOf() now syncs the folder after every project, symbol and title block save (QET::writeXmlText()) and every .qetz save (QetZip::write()). Nothing to do on Windows. --resave and --set-titleblock wrote a .qet straight over the output file (QFile, truncate, write): a run stopped part-way left half a project. They now write through QET::writeXmlText() like the GUI's save. On Windows their .qet now has LF line ends, as the GUI writes. Checked with strace on --resave: fdatasync of the new file, linkat into place, fsync of the folder. The .qet written is byte-identical to before. ctest 106/106. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 1 + sources/cli_export.cpp | 20 +++++------- sources/container/qetzip.cpp | 2 ++ sources/qet.cpp | 2 ++ sources/utils/syncfolder.h | 54 ++++++++++++++++++++++++++++++++ 5 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 sources/utils/syncfolder.h diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index fcedf5817..516ce5690 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -941,6 +941,7 @@ set(QET_SRC_FILES ${QET_DIR}/sources/utils/qetsettings.h ${QET_DIR}/sources/utils/qetutils.cpp ${QET_DIR}/sources/utils/qetutils.h + ${QET_DIR}/sources/utils/syncfolder.h ${QET_DIR}/sources/materiallist/materialentrydialog.cpp ${QET_DIR}/sources/materiallist/materialentrydialog.h diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index 5c4d5188f..802776cd8 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -885,14 +885,13 @@ int resaveProject(QETProject &project, const QString &output) //what building them would write (QETProject::toXml()) QHash kept_folios; const QDomDocument doc = project.toXml(&kept_folios); - QFile file(output); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - err << "Cannot open '" << output << "' for writing.\n"; + //Through a temporary file, as the GUI saves: a run stopped + //part-way leaves the previous file + QString error; + if (!QET::writeXmlText(QETProject::spliceKeptFolios(doc.toString(4), kept_folios), output, &error)) { + err << "Cannot write '" << output << "': " << error << "\n"; return 1; } - QTextStream fout(&file); - fout << QETProject::spliceKeptFolios(doc.toString(4), kept_folios); - file.close(); out << "Re-saved project -> " << output << "\n"; return 0; } @@ -980,14 +979,11 @@ int setTitleBlock(QETProject &project, const QString &output, //is what building them would write (QETProject::toXml()) QHash kept_folios; const QDomDocument doc = project.toXml(&kept_folios); - QFile file(output); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - err << "Cannot open '" << output << "' for writing.\n"; + QString error; + if (!QET::writeXmlText(QETProject::spliceKeptFolios(doc.toString(4), kept_folios), output, &error)) { + err << "Cannot write '" << output << "': " << error << "\n"; return 1; } - QTextStream fout(&file); - fout << QETProject::spliceKeptFolios(doc.toString(4), kept_folios); - file.close(); } out << "Stamped " << fields.size() << " field(s) on " << folios << " folio(s) -> " << output << "\n"; diff --git a/sources/container/qetzip.cpp b/sources/container/qetzip.cpp index dc9e2c9f7..3e0928905 100644 --- a/sources/container/qetzip.cpp +++ b/sources/container/qetzip.cpp @@ -18,6 +18,7 @@ #include "qetzip.h" #include "miniz/miniz.h" +#include "../utils/syncfolder.h" #include #include @@ -152,6 +153,7 @@ bool QetZip::write(const QString &path, const QList &entries, QString *er setError(error, file.errorString()); return false; } + QET::syncFolderOf(path); return true; } diff --git a/sources/qet.cpp b/sources/qet.cpp index 0f7a1c3af..03a042e89 100644 --- a/sources/qet.cpp +++ b/sources/qet.cpp @@ -19,6 +19,7 @@ #include "qetapp.h" #include "qeticons.h" #include "shortcutmanager.h" +#include "utils/syncfolder.h" #include #include @@ -809,6 +810,7 @@ bool QET::writeXmlText(const QString &text, const QString &filepath, QString *er return false; } + syncFolderOf(filepath); return(true); } diff --git a/sources/utils/syncfolder.h b/sources/utils/syncfolder.h new file mode 100644 index 000000000..d174cd9a1 --- /dev/null +++ b/sources/utils/syncfolder.h @@ -0,0 +1,54 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef SYNCFOLDER_H +#define SYNCFOLDER_H + +#include +#include +#include + +#ifndef Q_OS_WIN +#include +#include +#endif + +namespace QET { + /** + Force to disk the folder holding @p file_path, once + QSaveFile::commit() renamed a new file into it. QSaveFile syncs the + file it wrote but not the folder, and on some file systems (ext4, + XFS) a power cut soon after can then lose the rename: the previous + file comes back, or none. Does nothing on Windows, where a folder + cannot be synced this way. + */ + inline void syncFolderOf(const QString &file_path) + { +#ifndef Q_OS_WIN + const QByteArray folder = QFile::encodeName(QFileInfo(file_path).absolutePath()); + const int fd = ::open(folder.constData(), O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd >= 0) { + ::fsync(fd); + ::close(fd); + } +#else + Q_UNUSED(file_path) +#endif + } +} + +#endif // SYNCFOLDER_H From e3fa4a049c69cb12308ce5bb2861315a8d286f1a Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 08:41:27 +1300 Subject: [PATCH 2/7] .qetz: read the project database the way SQLite advises for untrusted files A .qetz's project.sqlite comes from a file, which may be damaged, made by another tool, edited by hand (issue #1458) or made to harm. SQLite's advice for such files (https://www.sqlite.org/security.html) was only partly followed: trusted_schema was off and every name had to be a plain table. readAsUntrusted() now also turns on the defensive mode, turns off triggers and views, checks each cell's size as its page is read, limits a value to 256 MiB, and runs quick_check before anything is read, for the database read on opening and for the one a save copies kept folios from. A damaged database is now refused as "the project database is damaged", where it was "cannot be read: database disk image is malformed" or a missing row. Damage on disk was already caught before SQLite: the zip checks each entry (one flipped bit in project.sqlite: refused, "unexpected decompressed size"). quick_check does not see a changed value inside a row (49 of 400 random small changes read silently, quick_check "ok"): only the zip's checksum does. New test damagedDatabaseRefused (a page overwritten at three places): fails without this change on the message. Opening nuri (136 folios): 3.56-3.66 s with and without. ctest: the 7 container, save and lazy folio suites pass. Co-Authored-By: Claude Opus 5.5 --- sources/container/qetcontainerdb.cpp | 36 ++++++++++++++++++++++++---- tests/qttest/tst_qetcontainer.cpp | 33 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/sources/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index aaab24a38..042d777a6 100644 --- a/sources/container/qetcontainerdb.cpp +++ b/sources/container/qetcontainerdb.cpp @@ -570,6 +570,32 @@ bool plainTables(sqlite3 *db) return ok && run(QStringLiteral("VACUUM")); } + //What SQLite advises for a database read from a file + //(https://www.sqlite.org/security.html): nothing in its schema runs, no + //trigger or view either, the defensive mode, no value longer than any + //project holds, and the size of each cell checked as its page is read. + //The database @p schema of @p db ("main", or an attached one) is then + //checked whole, so that damage is said as such before anything reads + //it, instead of showing as a missing row or not at all. +bool readAsUntrusted(Database &db, const QString &schema, QString *error) +{ + sqlite3_db_config(db.m_db, SQLITE_DBCONFIG_DEFENSIVE, 1, nullptr); + sqlite3_db_config(db.m_db, SQLITE_DBCONFIG_ENABLE_TRIGGER, 0, nullptr); +#ifdef SQLITE_DBCONFIG_ENABLE_VIEW + sqlite3_db_config(db.m_db, SQLITE_DBCONFIG_ENABLE_VIEW, 0, nullptr); +#endif + db.exec(QStringLiteral("PRAGMA trusted_schema = OFF")); + db.exec(QStringLiteral("PRAGMA cell_size_check = ON")); + sqlite3_limit(db.m_db, SQLITE_LIMIT_LENGTH, 256 * 1024 * 1024); + Statement check(db, QStringLiteral("PRAGMA %1.quick_check(1)").arg(schema)); + const QString result = check.next() ? check.value(0).toString() : db.error(); + if (result != QLatin1String("ok")) { + setError(error, QStringLiteral("the project database is damaged: %1").arg(result)); + return false; + } + return true; +} + //@p database opened in @p db, its tables checked to be plain tables and //its format one this reads: a file is untrusted bool openDatabase(Database &db, const QByteArray &database, QString *error) @@ -587,9 +613,10 @@ bool openDatabase(Database &db, const QByteArray &database, QString *error) setError(error, QStringLiteral("the project database cannot be read")); return false; } - //A file is untrusted: nothing in its schema may run, and every name - //read must be a plain table. - db.exec(QStringLiteral("PRAGMA trusted_schema = OFF")); + //A file is untrusted (readAsUntrusted()), and every name read must + //be a plain table. + if (!readAsUntrusted(db, QStringLiteral("main"), error)) + return false; for (const QString &table : DocTables) { Statement kind(db, QStringLiteral("SELECT type FROM sqlite_master WHERE name = ?")); kind.bind({table}); @@ -902,7 +929,8 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error, const QB setError(error, QStringLiteral("cannot read the database the kept folios come from")); return {}; } - db.exec(QStringLiteral("PRAGMA trusted_schema = OFF")); + if (!readAsUntrusted(db, QStringLiteral("old"), error)) + return {}; for (const QString &table : DocTables) { Statement kind(db, QStringLiteral("SELECT type FROM old.sqlite_master WHERE name = ?")); kind.bind({table}); diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 4c1c020dd..d1e574e31 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -3,6 +3,7 @@ #include #include +#include #include @@ -533,6 +534,38 @@ private slots: QVERIFY(!error.isEmpty()); } + // A page of the database overwritten, as a bad disk or a broken copy + // would: refused, and said to be damaged (quick_check on opening), + // whichever table the page held + void damagedDatabaseRefused_data() + { + QTest::addColumn("where"); + QTest::newRow("second page") << 0.0; + QTest::newRow("middle page") << 0.5; + QTest::newRow("last page") << 1.0; + } + void damagedDatabaseRefused() + { + QFETCH(double, where); + QDomDocument original; + QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + bool damaged = false; + for (QetZip::Entry &entry : entries) { + if (entry.name != QLatin1String("project.sqlite")) continue; + const int page_size = qFromBigEndian(entry.data.constData() + 16); + const int pages = int(entry.data.size() / page_size); + QVERIFY(pages > 3); + const int page = 1 + int(where * (pages - 2)); + entry.data.replace(page * page_size, page_size, QByteArray(page_size, char(0xff))); + damaged = true; + } + QVERIFY(damaged); + QDomDocument back; + QString error; + QVERIFY(!QetContainer::join(entries, &back, &error)); + QVERIFY2(error.contains(QStringLiteral("damaged")), qPrintable(error)); + } + // project.sqlite changed by @p sql in @p entries static void alterDatabase(QList &entries, const char *sql) { From fcc797342eeec20eda408800372770d59b391bf9 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 08:54:27 +1300 Subject: [PATCH 3/7] Fix a folio report pointing back at its partner's old place after its information changes A folio report's label shows where its partner is (updateReportText()). Changing any other information of the report -- a comment, a function -- ran elementInfoChanged(), which set the text from the label stored in the report's information instead: the partner's position when the file was last saved. Move the partner from column 4 to column 9, give the report a comment, and it showed (and saved) 1-C4 again. With lazy folios the partner is often built after the change, so the report was recomputed and the two modes saved different files; that is how this was found (FINDINGS F116, tools/lazy-folios/edit-diff.py). New tst_reporttextafterinfo: move the partner, comment the report, save, check the report says 1-C9, with folios built when needed and with every folio built. Both rows fail without this change ("1-C4"). Co-Authored-By: Claude Opus 5.5 --- .../dynamicelementtextitem.cpp | 13 +++ tests/qttest/CMakeLists.txt | 12 ++ tests/qttest/tst_reporttextafterinfo.cpp | 106 ++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 tests/qttest/tst_reporttextafterinfo.cpp diff --git a/sources/qetgraphicsitem/dynamicelementtextitem.cpp b/sources/qetgraphicsitem/dynamicelementtextitem.cpp index c99eb4a37..015b1cda8 100644 --- a/sources/qetgraphicsitem/dynamicelementtextitem.cpp +++ b/sources/qetgraphicsitem/dynamicelementtextitem.cpp @@ -1098,6 +1098,19 @@ void DynamicElementTextItem::handlerMouseReleaseEvent(QetGraphicsHandlerItem *ha void DynamicElementTextItem::elementInfoChanged() { + //A folio report's label shows where its partner is + //(updateReportText()); the label in its information is only where + //the partner was when last saved, and changing any other + //information wrote that stale position back (F116) + if (m_text_from == ElementInfo && m_info_name == QLatin1String("label") + && m_parent_element && (m_parent_element.data()->linkType() & Element::AllReport) + && m_other_report) { + setupFormulaConnection(); + updateReportText(); + emit plainTextChanged(); + return; + } + DiagramContext dc; Element *element = elementUseForInfo(); if(element) { diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 32f68efc8..7a834eb47 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -931,6 +931,18 @@ if(QET_HAS_SCRIPTING) "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # A folio report keeps showing where its partner is when another of its + # information changes (F116), on examples/iso_sfc_example.qet. + add_executable( + tst_reporttextafterinfo + tst_reporttextafterinfo.cpp) + add_test(NAME tst_reporttextafterinfo COMMAND tst_reporttextafterinfo) + add_dependencies(tst_reporttextafterinfo qelectrotech) + target_link_libraries(tst_reporttextafterinfo PRIVATE Qt::Test Qt::Xml) + target_compile_definitions(tst_reporttextafterinfo PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex() # turns element uuid + terminal uuid back into the index the conductor # calls take, on examples/perceuse.qet (two terminals at one point). diff --git a/tests/qttest/tst_reporttextafterinfo.cpp b/tests/qttest/tst_reporttextafterinfo.cpp new file mode 100644 index 000000000..4f8cc4acc --- /dev/null +++ b/tests/qttest/tst_reporttextafterinfo.cpp @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include +#include + +// A folio report shows where its partner is. Changing any other +// information of the report wrote back the label stored in its +// information, the partner's position when the file was last saved, so +// the report pointed at the wrong place until the partner moved again +// (FINDINGS F116). Runs a script through the real binary's --run on +// examples/iso_sfc_example.qet: the "jump to" of folio 1 moves from +// column 4 to column 9, then the "jump from" on folio 2 gets a comment. +class tst_reporttextafterinfo : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // The text a symbol @p uuid of @p saved shows for its label + static QString labelText(const QString &saved, const QString &uuid) + { + QFile file(saved); + if (!file.open(QIODevice::ReadOnly)) return QStringLiteral("(not saved)"); + QDomDocument doc; + if (!doc.setContent(&file)) return QStringLiteral("(not read)"); + const QDomNodeList elements = doc.elementsByTagName(QStringLiteral("element")); + for (int i = 0 ; i < elements.size() ; ++i) { + const QDomElement e = elements.at(i).toElement(); + if (e.attribute(QStringLiteral("uuid")) != uuid) continue; + const QDomNodeList texts = e.elementsByTagName(QStringLiteral("dynamic_elmt_text")); + for (int j = 0 ; j < texts.size() ; ++j) { + const QDomElement t = texts.at(j).toElement(); + if (t.firstChildElement(QStringLiteral("info_name")).text() == QLatin1String("label")) + return t.firstChildElement(QStringLiteral("text")).text(); + } + } + return QStringLiteral("(no such text)"); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void reportFollowsItsPartner_data() + { + QTest::addColumn("every_folio_built"); + QTest::newRow("folios built when needed") << false; + QTest::newRow("every folio built") << true; + } + void reportFollowsItsPartner() + { + QFETCH(bool, every_folio_built); + const QString tag = every_folio_built ? QStringLiteral("eager") : QStringLiteral("lazy"); + const QString saved = m_dir.filePath(tag + QStringLiteral(".qet")); + const QString script = m_dir.filePath(tag + QStringLiteral(".js")); + QFile f(script); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("qet.moveElement(0, '{bcf3d2e9-7190-48ed-bbb5-57bcc9ba8cfa}', 300, 0);\n" + "qet.setElementInfo(1, '{016d0266-33ac-4255-8fe2-4d9629a12d86}', 'comment', 'edited');\n" + "qet.save('" + saved.toUtf8() + "');\n"); + f.close(); + + // Columns numbered from 1, as QElectroTech's settings page + // shows by default + const QString home = m_dir.filePath(tag + QStringLiteral("-home")); + QVERIFY(QDir().mkpath(home + QStringLiteral("/config/QElectroTech"))); + QFile conf(home + QStringLiteral("/config/QElectroTech/QElectroTech.conf")); + QVERIFY(conf.open(QIODevice::WriteOnly)); + conf.write("[General]\nborder-columns_0=false\n"); + conf.close(); + + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + if (every_folio_built) + env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1")); + else + env.remove(QStringLiteral("QET_EAGER_FOLIOS")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), + {QStringLiteral("--run"), script, + QStringLiteral(QET_EXAMPLES_DIR "/iso_sfc_example.qet")}); + QVERIFY(proc.waitForFinished(120000)); + + // The partner, moved, and the report that shows where it is + QCOMPARE(labelText(saved, QStringLiteral("{016d0266-33ac-4255-8fe2-4d9629a12d86}")), + QStringLiteral("1-C9")); + } +}; + +QTEST_APPLESS_MAIN(tst_reporttextafterinfo) + +#include "tst_reporttextafterinfo.moc" From e8201abb8a331e7df9fdd89eea653af3bf5cbed1 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 09:00:07 +1300 Subject: [PATCH 4/7] Keep the previous versions of a project when saving it Saving replaced the project file with nothing kept of what it was: one bad save -- a fault in QElectroTech, a full disk, a wrong edit saved by mistake -- and the last good version was gone. AutoCAD keeps a .bak on every save, KiCad several backups. Before a project is saved over, the file as it was is now copied to a folder beside it, "-backups", named by the time that version was saved ("industrial 2026-10-16 08-30-12.qetz"); only the newest are kept, 5 by default (Settings > General > Projects, 0 for none). A copy is never a move, so a save that fails leaves the file in place; a copy that fails only warns, the save goes on. The same bytes as the newest version kept are not kept twice. The prompt on opening ("Create a backup copy?"), which writes one dated copy beside the project and never removes any, is left as it is. New tst_previousversions (newest kept, same bytes once, same second, nothing for a new file or a count of 0, a failed copy said); it fails with the oldest never removed. Checked with the program: two scripted saves of a .qetz leave the original and the first save in industrial-backups, the project holds the second. ctest 107/107. Co-Authored-By: Claude Opus 5.5 --- cmake/qet_compilation_vars.cmake | 2 + sources/qetproject.cpp | 8 ++ .../configpage/generalconfigurationpage.cpp | 3 + .../ui/configpage/generalconfigurationpage.ui | 24 ++++ sources/utils/previousversions.cpp | 113 ++++++++++++++++ sources/utils/previousversions.h | 49 +++++++ tests/qttest/CMakeLists.txt | 11 ++ tests/qttest/tst_previousversions.cpp | 127 ++++++++++++++++++ 8 files changed, 337 insertions(+) create mode 100644 sources/utils/previousversions.cpp create mode 100644 sources/utils/previousversions.h create mode 100644 tests/qttest/tst_previousversions.cpp diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index fcedf5817..1b4851052 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -940,6 +940,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/utils/qetsettings.cpp ${QET_DIR}/sources/utils/qetsettings.h ${QET_DIR}/sources/utils/qetutils.cpp + ${QET_DIR}/sources/utils/previousversions.cpp + ${QET_DIR}/sources/utils/previousversions.h ${QET_DIR}/sources/utils/qetutils.h ${QET_DIR}/sources/materiallist/materialentrydialog.cpp diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 45ef03635..8f04cca20 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -48,6 +48,7 @@ #include "qetinformation.h" #include "qetversion.h" #include "undocommand/adddiagramcommand.h" +#include "utils/previousversions.h" #include #include @@ -2759,6 +2760,13 @@ QETResult QETProject::write() return(QString("the file %1 was opened read-only and thus will not be written").arg(m_file_path)); } + //The file as it was goes among its previous versions first + //(Settings > General > Projects); a copy that fails does not stop + //the save + QString backup_error; + if (!PreviousVersions::keep(m_file_path, PreviousVersions::count(), &backup_error)) + qWarning().noquote() << "Previous version of" << m_file_path << "not kept:" << backup_error; + QString error_message; if (isContainerPath(m_file_path)) { diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index ccbc24cdd..68ef91306 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -26,6 +26,7 @@ #include "../../cablelist/cabletypelist.h" #include "../../utils/qetsettings.h" #include "../../utils/qetutils.h" +#include "../../utils/previousversions.h" #include "../../qetmessagebox.h" #include "../../textgrid.h" #include "../../wiringrules.h" @@ -190,6 +191,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool()); ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool()); ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt()); + ui->m_previous_versions_sb->setValue(PreviousVersions::count()); QString fontInfos = settings.value("diagramitemfont", "Liberation Sans").toString() + " " + settings.value("diagramitemsize", "9").toString() + " (" + @@ -417,6 +419,7 @@ void GeneralConfigurationPage::applyConf() } } settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value()); + PreviousVersions::setCount(ui->m_previous_versions_sb->value()); settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked()); settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked()); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index b0f0d0d6e..88858fe8e 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -365,6 +365,29 @@ + + + Previous versions kept when saving a project + + + Before a project is saved, the file as it was is copied to a folder beside it, named after the project with "-backups". Only the newest are kept. + + + + + + + Before a project is saved, the file as it was is copied to a folder beside it, named after the project with "-backups". Only the newest are kept. + + + None + + + 100 + + + + Qt::Vertical @@ -1534,6 +1557,7 @@ m_use_folio_label m_border_0 m_autosave_sb + m_previous_versions_sb m_common_elmt_path_cb m_custom_elmt_path_cb m_custom_tbt_path_cb diff --git a/sources/utils/previousversions.cpp b/sources/utils/previousversions.cpp new file mode 100644 index 000000000..9d741d3d0 --- /dev/null +++ b/sources/utils/previousversions.cpp @@ -0,0 +1,113 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "previousversions.h" + +#include +#include +#include +#include +#include +#include + +namespace { +const QString CountKey = QStringLiteral("diagrameditor/previous-versions"); +constexpr int DefaultCount = 5; + + //What a version of @p info is named, without the extension: its base + //name and a time +QString versionPrefix(const QFileInfo &info) +{ + return info.completeBaseName() + QLatin1Char(' '); +} + +QByteArray digest(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + return hash.result(); +} +} + +int PreviousVersions::count() +{ + return qMax(0, QSettings().value(CountKey, DefaultCount).toInt()); +} + +void PreviousVersions::setCount(int count) +{ + QSettings().setValue(CountKey, qMax(0, count)); +} + +QString PreviousVersions::folder(const QString &file_path) +{ + const QFileInfo info(file_path); + return info.absoluteDir().filePath(info.completeBaseName() + QStringLiteral("-backups")); +} + +QStringList PreviousVersions::kept(const QString &file_path) +{ + const QFileInfo info(file_path); + const QDir dir(folder(file_path)); + //Each copy has the time its version was saved (keep()); a .qet and + //a .qetz of the same project are versions of it alike + QStringList names = dir.entryList({versionPrefix(info) + QStringLiteral("????-??-?? ??-??-??*.qet"), + versionPrefix(info) + QStringLiteral("????-??-?? ??-??-??*.qetz")}, + QDir::Files, QDir::Time); + QStringList paths; + for (const QString &name : std::as_const(names)) + paths << dir.filePath(name); + return paths; +} + +bool PreviousVersions::keep(const QString &file_path, int count, QString *error) +{ + const QFileInfo info(file_path); + if (count <= 0 || !info.isFile()) return true; + + const QStringList versions = kept(file_path); + if (!versions.isEmpty() && QFileInfo(versions.first()).size() == info.size() + && digest(versions.first()) == digest(file_path)) + return true; + + QDir dir(folder(file_path)); + if (!dir.exists() && !QDir().mkpath(dir.path())) { + if (error) *error = QStringLiteral("cannot create the folder %1").arg(dir.path()); + return false; + } + //Named by the time this version was saved, made unique if another + //was saved the same second + const QDateTime saved = info.lastModified(); + const QString stem = versionPrefix(info) + saved.toString(QStringLiteral("yyyy-MM-dd HH-mm-ss")); + QString target = dir.filePath(stem + QLatin1Char('.') + info.suffix()); + for (int n = 2 ; QFileInfo::exists(target) ; ++n) + target = dir.filePath(stem + QStringLiteral(" (%1).").arg(n) + info.suffix()); + if (!QFile::copy(file_path, target)) { + if (error) *error = QStringLiteral("cannot copy %1 to %2").arg(file_path, target); + return false; + } + QFile copy(target); + if (copy.open(QIODevice::ReadWrite)) + copy.setFileTime(saved, QFileDevice::FileModificationTime); + + const QStringList all = kept(file_path); + for (int i = count ; i < all.size() ; ++i) + QFile::remove(all.at(i)); + return true; +} diff --git a/sources/utils/previousversions.h b/sources/utils/previousversions.h new file mode 100644 index 000000000..16844eaad --- /dev/null +++ b/sources/utils/previousversions.h @@ -0,0 +1,49 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef PREVIOUSVERSIONS_H +#define PREVIOUSVERSIONS_H + +#include +#include + +/** + @brief The PreviousVersions namespace + The versions of a project file kept before it is saved over, as KiCad + and AutoCAD keep theirs: a copy of the file as it was goes in a folder + beside it, "-backups", named by the time that version was saved + ("industrial 2026-10-16 08-30-12.qetz"), and only the newest are kept. + The file is copied, never moved: a save that fails leaves it in place. +*/ +namespace PreviousVersions +{ + /// How many versions to keep (Settings > General > Projects); 0 keeps none + int count(); + void setCount(int count); + /// The folder the versions of @p file_path go in + QString folder(const QString &file_path); + /// The versions kept of @p file_path, newest first + QStringList kept(const QString &file_path); + /// Copy @p file_path, about to be saved over, among its versions and + /// keep only the @p count newest. Nothing is copied when the file does + /// not exist, when @p count is 0, or when the newest version kept + /// holds the same bytes. @return false, with @p error, when the copy + /// could not be made: the save goes on all the same. + bool keep(const QString &file_path, int count, QString *error = nullptr); +} + +#endif // PREVIOUSVERSIONS_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 32f68efc8..417cb55e7 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1264,6 +1264,17 @@ if(Qt6_VERSION VERSION_LESS 6.5.0) endforeach() endif() +# The versions of a project kept before it is saved over: the newest +# kept, same bytes not kept twice, a failed copy said +# (sources/utils/previousversions.*). +add_executable( + tst_previousversions + tst_previousversions.cpp + ${QET_DIR}/sources/utils/previousversions.cpp) +add_test(NAME tst_previousversions COMMAND tst_previousversions) +target_include_directories(tst_previousversions PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_previousversions PRIVATE Qt::Test) + # The zip a .qetz project is stored in: round trip, same bytes twice, # unsafe entry names refused (sources/container/qetzip.*, bundled miniz). add_executable( diff --git a/tests/qttest/tst_previousversions.cpp b/tests/qttest/tst_previousversions.cpp new file mode 100644 index 000000000..55cb6927c --- /dev/null +++ b/tests/qttest/tst_previousversions.cpp @@ -0,0 +1,127 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include + +#include +#include +#include + +#include "utils/previousversions.h" + +// The versions of a project kept before it is saved over +// (sources/utils/previousversions.*) +class tst_previousversions : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // @p path holding @p text, saved at @p when + static void write(const QString &path, const QByteArray &text, const QDateTime &when) + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(text); + file.flush(); //else closing writes, and sets the time to now + QVERIFY(file.setFileTime(when, QFileDevice::FileModificationTime)); + } + + static QByteArray read(const QString &path) + { + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + } + +private slots: + void initTestCase() { QVERIFY(m_dir.isValid()); } + + // Each save keeps the file as it was, newest first, named by the time + // that version was saved, in "-backups" beside it; only the + // newest are kept + void keepsTheNewest() + { + const QString project = m_dir.filePath(QStringLiteral("plant.qetz")); + const QDateTime start(QDate(2026, 10, 16), QTime(8, 0, 0)); + for (int i = 0 ; i < 7 ; ++i) { + write(project, "version " + QByteArray::number(i), start.addSecs(60 * i)); + QVERIFY(PreviousVersions::keep(project, 3)); + } + const QStringList kept = PreviousVersions::kept(project); + QCOMPARE(kept.size(), 3); + QCOMPARE(read(kept.at(0)), QByteArray("version 6")); + QCOMPARE(read(kept.at(2)), QByteArray("version 4")); + QCOMPARE(QFileInfo(kept.at(0)).fileName(), QStringLiteral("plant 2026-10-16 08-06-00.qetz")); + QCOMPARE(QFileInfo(kept.at(0)).absolutePath(), m_dir.filePath(QStringLiteral("plant-backups"))); + // The project itself is untouched + QCOMPARE(read(project), QByteArray("version 6")); + } + + // Saving the same bytes again keeps no second copy + void sameBytesNotKeptTwice() + { + const QString project = m_dir.filePath(QStringLiteral("same.qet")); + const QDateTime when(QDate(2026, 10, 16), QTime(9, 0, 0)); + write(project, "unchanged", when); + QVERIFY(PreviousVersions::keep(project, 5)); + write(project, "unchanged", when.addSecs(30)); + QVERIFY(PreviousVersions::keep(project, 5)); + QCOMPARE(PreviousVersions::kept(project).size(), 1); + } + + // Two versions saved the same second both kept + void sameSecond() + { + const QString project = m_dir.filePath(QStringLiteral("fast.qetz")); + const QDateTime when(QDate(2026, 10, 16), QTime(10, 0, 0)); + write(project, "first", when); + QVERIFY(PreviousVersions::keep(project, 5)); + write(project, "second", when); + QVERIFY(PreviousVersions::keep(project, 5)); + QCOMPARE(PreviousVersions::kept(project).size(), 2); + } + + // Nothing kept for a new file, or when the count is 0; another + // project's versions in the same folder are not counted + void nothingToKeep() + { + const QString project = m_dir.filePath(QStringLiteral("new.qetz")); + QVERIFY(PreviousVersions::keep(project, 5)); + QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project))); + write(project, "x", QDateTime(QDate(2026, 10, 16), QTime(11, 0, 0))); + QVERIFY(PreviousVersions::keep(project, 0)); + QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project))); + QVERIFY(PreviousVersions::kept(m_dir.filePath(QStringLiteral("other.qetz"))).isEmpty()); + } + + // A copy that cannot be made says so, and leaves the project as it is + void copyFails() + { + const QString project = m_dir.filePath(QStringLiteral("blocked.qetz")); + write(project, "data", QDateTime(QDate(2026, 10, 16), QTime(12, 0, 0))); + // A file where the folder should be + write(PreviousVersions::folder(project), "", QDateTime::currentDateTime()); + QString error; + QVERIFY(!PreviousVersions::keep(project, 5, &error)); + QVERIFY(!error.isEmpty()); + QCOMPARE(read(project), QByteArray("data")); + } +}; + +QTEST_GUILESS_MAIN(tst_previousversions) + +#include "tst_previousversions.moc" From 2bd19e04be4ca02df534b26d01be33430a725c61 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 10:15:37 +1300 Subject: [PATCH 5/7] Add recovering what can be read of a damaged .qetz (--recover) A .qetz that does not open as it is -- a picture's file missing (as the title-change fault wrote them), a folio file missing, a row deleted from project.sqlite by hand (issue #1458), a damaged database -- was refused whole, and nothing in QElectroTech could get anything back. QetContainer::recover() reads what it can and says what it left out: an entry that cannot be unpacked, a missing folio file or picture, a symbol or wire that does not match the database, and, when the database cannot be read at all, the information it held (the wires go too, as it holds their ends). join(), restore() and QetZip::fromBytes() take an optional list for this; given none they refuse as before. `qelectrotech --recover damaged.qetz out.qet` writes what was read to a .qet and lists what was left out. Measured on industrial (50 folios, 618 symbols, 671 wires): a wire row deleted -> 670 wires, the rest whole; a folio file missing -> 49 folios; a database page overwritten -> every folio and symbol drawn, no wires and no symbol information. A zip cut short (no central directory) still cannot be read. Tests: recoverLeavesOutOnlyTheDamage (nothing, folio file, wire row, database page), recoverMissingPicture, recoverNotAZipRefused; they fail with the database side kept strict. The strict cases pass unchanged. ctest 106/106. Co-Authored-By: Claude Opus 5.5 --- sources/cli_export.cpp | 48 +++++++++++ sources/cli_export.h | 1 + sources/container/qetcontainer.cpp | 72 ++++++++++++---- sources/container/qetcontainer.h | 6 +- sources/container/qetcontainerdb.cpp | 124 ++++++++++++++++++++------- sources/container/qetcontainerdb.h | 9 +- sources/container/qetzip.cpp | 24 +++++- sources/container/qetzip.h | 4 +- tests/qttest/tst_qetcontainer.cpp | 114 ++++++++++++++++++++++++ 9 files changed, 349 insertions(+), 53 deletions(-) diff --git a/sources/cli_export.cpp b/sources/cli_export.cpp index 5c4d5188f..262cf1e88 100644 --- a/sources/cli_export.cpp +++ b/sources/cli_export.cpp @@ -28,6 +28,7 @@ #include "dxfexport.h" #include "exportproperties.h" #include "pdf_links.h" +#include "container/qetcontainer.h" #include "qetgraphicsitem/conductor.h" #include "qetgraphicsitem/diagramimageitem.h" #include "qetgraphicsitem/element.h" @@ -85,6 +86,7 @@ const QHash &exportFlags() {"--info", "info"}, {"--check-elements", "check"}, {"--resave", "resave"}, + {"--recover", "recover"}, {"--set-titleblock", "settb"}, }; return flags; @@ -870,6 +872,42 @@ int exportLinks(QETProject &project, const QString &output) /// Round-trip: load the project and write its XML back out, so an external /// diff can reveal markup QET silently normalises (tolerated-but-invalid XML). +/// What can be read of the damaged .qetz @p input, written to the .qet +/// @p output, and what was left out (QetContainer::recover()). +int recoverProject(const QString &input, const QString &output) +{ + QFile file(input); + if (!file.open(QIODevice::ReadOnly)) { + err << "Cannot read '" << input << "': " << file.errorString() << "\n"; + return 2; + } + const QByteArray content = file.readAll(); + if (!QetContainer::isZip(content)) { + err << "'" << input << "' is not a .qetz project; a .qet opens as it is, or can be fixed in a text editor\n"; + return 2; + } + QDomDocument document; + QStringList lost; + QString error; + if (!QetContainer::recover(content, &document, &lost, &error)) { + err << "Nothing can be recovered from '" << input << "': " << error << "\n"; + return 1; + } + if (!QET::writeXmlFile(document, output, &error)) { + err << "Cannot write '" << output << "': " << error << "\n"; + return 1; + } + out << "Recovered " << input << " -> " << output << "\n"; + if (lost.isEmpty()) { + out << "Nothing was left out.\n"; + } else { + out << "Left out:\n"; + for (const QString &item : std::as_const(lost)) + out << " " << item << "\n"; + } + return 0; +} + int resaveProject(QETProject &project, const QString &output) { if (QETProject::isContainerPath(output)) { @@ -1042,6 +1080,16 @@ int run(const QStringList &args) return checkElements(rest.at(0)); } + // --recover reads what it can of a damaged .qetz, which a project + // cannot be opened from. + if (format == "recover") { + if (rest.size() < 2) { + err << "Usage: qelectrotech --recover \n"; + return 2; + } + return recoverProject(rest.at(0), rest.at(1)); + } + const QString input = rest.value(0); if (input.isEmpty()) { err << "Usage: qelectrotech " << flag << " \n"; diff --git a/sources/cli_export.h b/sources/cli_export.h index 09f66826f..357746403 100644 --- a/sources/cli_export.h +++ b/sources/cli_export.h @@ -55,6 +55,7 @@ namespace CLIExport { qelectrotech --info [output.json] qelectrotech --check-elements qelectrotech --resave + qelectrotech --recover qelectrotech --set-titleblock key=value... PDF: one multi-page document (one diagram per page). diff --git a/sources/container/qetcontainer.cpp b/sources/container/qetcontainer.cpp index 406e43751..95f306c0e 100644 --- a/sources/container/qetcontainer.cpp +++ b/sources/container/qetcontainer.cpp @@ -184,6 +184,25 @@ bool QetContainer::readDocument(const QByteArray &content, QDomDocument *documen && join(entries, document, error, nullptr, pictures, format); } +/** + @brief QetContainer::recover + What can be read of the .qetz @p content, a damaged one, into @p project: + a folio file, a picture or an entry that cannot be read is left out, + symbols and wires that do not match the database lose what it held, and + a database that cannot be read loses it all (wires, whose ends it holds, + go too). Each thing left out is said in @p lost. + @return false, with @p error, when nothing can be read: not a zip, no + project.xml, or a later format +*/ +bool QetContainer::recover(const QByteArray &content, QDomDocument *project, QStringList *lost, QString *error) +{ + QStringList none; + if (!lost) lost = &none; + QList entries; + return QetZip::fromBytes(content, &entries, error, lost) + && join(entries, project, error, nullptr, nullptr, nullptr, lost); +} + QString QetContainer::pictureFile(const QDomElement &image) { return image.attribute(ImageFileAttribute); @@ -421,23 +440,35 @@ QList QetContainer::split(const QDomDocument &project, const QStr for a later one this may read (its min-reader allows it). */ bool QetContainer::join(const QList &entries, QDomDocument *project, QString *error, - QStringList *folio_files, Pictures *pictures, int *format) + QStringList *folio_files, Pictures *pictures, int *format, QStringList *lost) { + //Recovering a damaged project (recover()): what is missing or + //damaged is noted in lost and left out, instead of refusing it all + const auto noted = [error, lost](const QString &problem) { + if (lost) { + lost->append(problem); + return true; + } + setError(error, problem); + return false; + }; QHash files; for (const QetZip::Entry &entry : entries) files.insert(entry.name, entry.data); const QByteArray mimetype = files.value(QStringLiteral("mimetype")); - if (mimetype != QByteArray(MimeType) && mimetype != QByteArray(OldMimeType)) { - setError(error, QStringLiteral("not a QElectroTech project (.qetz)")); + if (mimetype != QByteArray(MimeType) && mimetype != QByteArray(OldMimeType) + && !noted(QStringLiteral("not a QElectroTech project (.qetz)"))) return false; - } QDomDocument manifest; - if (!parse(files.value(QStringLiteral("manifest.xml")), &manifest, error)) + QString manifest_error; + const bool has_manifest = parse(files.value(QStringLiteral("manifest.xml")), &manifest, &manifest_error); + if (!has_manifest && !noted(manifest_error)) return false; bool ok = false; const int min_reader = manifest.documentElement().attribute(QStringLiteral("min-reader")).toInt(&ok); - if (!ok || min_reader > Format) { + //A later format is not damage: refused all the same + if (has_manifest && (!ok || min_reader > Format)) { setError(error, QStringLiteral("made by a later QElectroTech (format %1, this one reads up to %2)") .arg(manifest.documentElement().attribute(QStringLiteral("format"))) .arg(Format)); @@ -460,29 +491,33 @@ bool QetContainer::join(const QList &entries, QDomDocument *proje && file.startsWith(QLatin1String("folios/"))) folio_files->append(file); if (!files.contains(file)) { - setError(error, QStringLiteral("part \"%1\" is missing").arg(file)); - return false; + if (!noted(QStringLiteral("part \"%1\" is missing").arg(file))) return false; + part.parentNode().removeChild(part); + continue; } QDomDocument document; QString part_error; if (!parse(files.value(file), &document, &part_error)) { - setError(error, QStringLiteral("%1: %2").arg(file, part_error)); - return false; + if (!noted(QStringLiteral("%1: %2").arg(file, part_error))) return false; + part.parentNode().removeChild(part); + continue; } part.parentNode().replaceChild(project->importNode(document.documentElement(), true), part); } //Pictures back to base64, as the image's first child, or into //pictures - const QDomNodeList images = project->elementsByTagName(QStringLiteral("image")); - for (int i = 0 ; i < images.size() ; ++i) + const QDomNodeList image_list = project->elementsByTagName(QStringLiteral("image")); + QList images; + for (int i = 0 ; i < image_list.size() ; ++i) images << image_list.at(i).toElement(); + for (QDomElement image : images) { - QDomElement image = images.at(i).toElement(); if (!image.hasAttribute(ImageFileAttribute)) continue; const QString file = image.attribute(ImageFileAttribute); if (!files.contains(file)) { - setError(error, QStringLiteral("picture \"%1\" is missing").arg(file)); - return false; + if (!noted(QStringLiteral("picture \"%1\" is missing").arg(file))) return false; + image.parentNode().removeChild(image); + continue; } if (pictures) { pictures->insert(file, files.value(file)); @@ -496,7 +531,12 @@ bool QetContainer::join(const QList &entries, QDomDocument *proje //The engineering data back from project.sqlite (format 2 on) if (files.contains(QStringLiteral("project.sqlite")) && !QetContainerDb::restore(project->documentElement(), - files.value(QStringLiteral("project.sqlite")), error)) + files.value(QStringLiteral("project.sqlite")), error, lost)) return false; + if (lost) { + if (!files.contains(QStringLiteral("project.sqlite"))) + lost->append(QStringLiteral("project.sqlite is missing")); + QetContainerDb::dropLeftovers(project->documentElement(), lost); + } return true; } diff --git a/sources/container/qetcontainer.h b/sources/container/qetcontainer.h index 404aa630b..7447fafc3 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -77,7 +77,11 @@ namespace QetContainer const QHash &kept_from = QHash()); bool join(const QList &entries, QDomDocument *project, QString *error = nullptr, QStringList *folio_files = nullptr, - Pictures *pictures = nullptr, int *format = nullptr); + Pictures *pictures = nullptr, int *format = nullptr, + QStringList *lost = nullptr); + /// What can be read of a damaged .qetz, and in @p lost what could not + bool recover(const QByteArray &content, QDomDocument *project, QStringList *lost, + QString *error = nullptr); /// The file join() left the picture of @p image in, or an empty string QString pictureFile(const QDomElement &image); diff --git a/sources/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index aaab24a38..4b46ca703 100644 --- a/sources/container/qetcontainerdb.cpp +++ b/sources/container/qetcontainerdb.cpp @@ -749,8 +749,22 @@ struct UsedRows } }; - //The engineering data of the folio @p d back from @p db -bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error) + //A database that does not match its folios: refused, or, with @p lost + //(a damaged project recovered, QetContainer::recover()), @p problem + //noted and gone past. @return true to go on +bool noted(QString *error, QStringList *lost, const QString &problem) +{ + if (lost) { + lost->append(problem); + return true; + } + setError(error, problem); + return false; +} + + //The engineering data of the folio @p d back from @p db; with @p lost, + //what does not match is noted and left out (noted()) +bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error, QStringList *lost) { QDomDocument document = d.ownerDocument(); auto entry = [&document](const QVariant &name_attr, const QString &name, const QVariant &value, @@ -759,10 +773,11 @@ bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error) }; const QString id = keyOf(d, FolioAttribute); if (id.isEmpty()) return true; - if (!used.use(used.folios, id, QStringLiteral("folio"), error)) return false; + QString problem; + if (!used.use(used.folios, id, QStringLiteral("folio"), &problem)) return noted(error, lost, problem); Statement f(db, QStringLiteral("SELECT %1, props_attrs FROM folio WHERE uuid = ?").arg(quoted(FolioData))); f.bind({id}); - if (!f.next()) { setError(error, QStringLiteral("folio %1 is not in the database").arg(id)); return false; } + if (!f.next()) return noted(error, lost, QStringLiteral("folio %1 is not in the database").arg(id)); QVariantList values; for (int i = 0 ; i < FolioData.size() + 1 ; ++i) values << f.value(i); put(d, FolioData, values, 0); @@ -777,24 +792,30 @@ bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error) props.appendChild(entry(p.value(0), QStringLiteral("name"), p.value(1), p.value(2), QStringLiteral("property"), p.value(3))); restoreWhitespace(props, values.at(FolioData.size())); - if (!replaceMarker(d, props)) { - setError(error, QStringLiteral("folio %1's properties have no place").arg(id)); + if (!replaceMarker(d, props) + && !noted(error, lost, QStringLiteral("folio %1's properties have no place").arg(id))) return false; - } } for (const QDomElement &list : children(d, QStringLiteral("elements"))) { for (QDomElement e : children(list, QStringLiteral("element"))) { const QString key = keyOf(e, KeyAttribute); if (key.isEmpty()) continue; - if (!used.use(used.elements, key, QStringLiteral("symbol"), error)) return false; + if (!used.use(used.elements, key, QStringLiteral("symbol"), &problem)) { + if (!noted(error, lost, problem)) return false; + continue; + } Statement s(db, QStringLiteral("SELECT %1, info_attrs, links_attrs, folio_uuid FROM element WHERE uuid = ?") .arg(quoted(ElementData))); s.bind({key}); - if (!s.next()) { setError(error, QStringLiteral("symbol %1 is not in the database").arg(key)); return false; } + if (!s.next()) { + if (!noted(error, lost, QStringLiteral("symbol %1 is not in the database").arg(key))) return false; + continue; + } if (s.value(ElementData.size() + 2).toString() != id) { - setError(error, QStringLiteral("symbol %1 is on folio %2, the database says another").arg(key, id)); - return false; + if (!noted(error, lost, QStringLiteral("symbol %1 is on folio %2, the database says another").arg(key, id))) + return false; + continue; } QVariantList v; for (int i = 0 ; i < ElementData.size() + 2 ; ++i) v << s.value(i); @@ -821,25 +842,31 @@ bool restoreDiagram(Database &db, QDomElement d, UsedRows &used, QString *error) QStringLiteral("link_uuid"))); restoreWhitespace(links, v.at(n + 1)); } - if ((!info.isNull() && !replaceMarker(e, info)) - || (!links.isNull() && !replaceMarker(e, links))) { - setError(error, QStringLiteral("symbol %1's information has no place").arg(key)); + if (((!info.isNull() && !replaceMarker(e, info)) + || (!links.isNull() && !replaceMarker(e, links))) + && !noted(error, lost, QStringLiteral("symbol %1's information has no place").arg(key))) return false; - } } } for (const QDomElement &list : children(d, QStringLiteral("conductors"))) { for (QDomElement c : children(list, QStringLiteral("conductor"))) { const QString key = keyOf(c, KeyAttribute); if (key.isEmpty()) continue; - if (!used.use(used.conductors, key, QStringLiteral("wire"), error)) return false; + if (!used.use(used.conductors, key, QStringLiteral("wire"), &problem)) { + if (!noted(error, lost, problem)) return false; + continue; + } Statement s(db, QStringLiteral("SELECT %1, folio_uuid FROM conductor WHERE uuid = ?") .arg(quoted(ConductorData))); s.bind({key}); - if (!s.next()) { setError(error, QStringLiteral("wire %1 is not in the database").arg(key)); return false; } + if (!s.next()) { + if (!noted(error, lost, QStringLiteral("wire %1 is not in the database").arg(key))) return false; + continue; + } if (s.value(ConductorData.size()).toString() != id) { - setError(error, QStringLiteral("wire %1 is on folio %2, the database says another").arg(key, id)); - return false; + if (!noted(error, lost, QStringLiteral("wire %1 is on folio %2, the database says another").arg(key, id))) + return false; + continue; } QVariantList v; for (int i = 0 ; i < ConductorData.size() ; ++i) v << s.value(i); @@ -1135,12 +1162,14 @@ QByteArray QetContainerDb::extract(QDomElement project, QString *error, const QB Put the engineering data in @p database back into @p project (the element, its folios joined back already). */ -bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QString *error) +bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QString *error, + QStringList *lost) { QDomDocument document = project.ownerDocument(); Database db; - if (!openDatabase(db, database, error) || !readyToRead(db, project, error)) - return false; + QString problem; + if (!openDatabase(db, database, &problem) || !readyToRead(db, project, &problem)) + return noted(error, lost, problem); //Project properties QHash meta; @@ -1156,10 +1185,9 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS props.appendChild(entryElement(document, s.value(0), QStringLiteral("name"), s.value(1), s.value(2), QStringLiteral("property"), s.value(3))); restoreWhitespace(props, meta.value(QStringLiteral("props_attrs"))); - if (!replaceMarker(project, props)) { - setError(error, QStringLiteral("the project's properties have no place")); + if (!replaceMarker(project, props) + && !noted(error, lost, QStringLiteral("the project's properties have no place"))) return false; - } } if (meta.contains(QStringLiteral("terminal_strips"))) { @@ -1176,17 +1204,53 @@ bool QetContainerDb::restore(QDomElement project, const QByteArray &database, QS read(QStringLiteral("SELECT * FROM strip_terminal ORDER BY strip_uuid, pos, level"), 4, &rows.terminals); read(QStringLiteral("SELECT * FROM strip_bridge ORDER BY ord"), 4, &rows.bridges); read(QStringLiteral("SELECT * FROM strip_bridge_terminal ORDER BY bridge_uuid, ord"), 3, &rows.bridge_terminals); - if (!replaceMarker(project, stripsXml(document, rows))) { - setError(error, QStringLiteral("the terminal strips have no place")); + if (!replaceMarker(project, stripsXml(document, rows)) + && !noted(error, lost, QStringLiteral("the terminal strips have no place"))) return false; - } } UsedRows used; for (QDomElement d : children(project, QStringLiteral("diagram"))) - if (!restoreDiagram(db, d, used, error)) + if (!restoreDiagram(db, d, used, error, lost)) return false; - return noRowLeft(db, used, error); + if (!noRowLeft(db, used, &problem)) + return noted(error, lost, problem); + return true; +} + +/** + @brief QetContainerDb::dropLeftovers + After restore() of a damaged project (with lost): take out of + @p project what only the database could have filled -- the markers of + blocks it held, the keys of rows -- and the wires whose ends it held, + which cannot be drawn without them. Each kind left out is said in @p lost. +*/ +void QetContainerDb::dropLeftovers(QDomElement project, QStringList *lost) +{ + QList markers, wires; + QList all{project}; + for (int i = 0 ; i < all.size() ; ++i) { + QDomElement e = all.at(i); + for (QDomElement c = e.firstChildElement() ; !c.isNull() ; c = c.nextSiblingElement()) + all << c; + if (e.tagName() == MarkerTag) markers << e; + e.removeAttribute(KeyAttribute); + e.removeAttribute(FolioAttribute); + e.removeAttribute(KeptPartAttribute); + if (e.tagName() == QLatin1String("conductor") && e.parentNode().nodeName() == QLatin1String("conductors") + //Every format names a wire's ends by terminal (older files + //by terminal only), and a .qetz holds them in the database + && (!e.hasAttribute(QStringLiteral("terminal1")) || !e.hasAttribute(QStringLiteral("terminal2")))) + wires << e; + } + for (QDomElement m : std::as_const(markers)) + m.parentNode().removeChild(m); + for (QDomElement w : std::as_const(wires)) + w.parentNode().removeChild(w); + if (lost && !markers.isEmpty()) + lost->append(QStringLiteral("%1 blocks of information the database held").arg(markers.size())); + if (lost && !wires.isEmpty()) + lost->append(QStringLiteral("%1 wires, whose ends the database held").arg(wires.size())); } /** diff --git a/sources/container/qetcontainerdb.h b/sources/container/qetcontainerdb.h index 4f4b31408..66b8fa7d0 100644 --- a/sources/container/qetcontainerdb.h +++ b/sources/container/qetcontainerdb.h @@ -20,6 +20,7 @@ #include #include +#include /** @brief The QetContainerDb namespace @@ -41,7 +42,13 @@ namespace QetContainerDb { QByteArray extract(QDomElement project, QString *error = nullptr, const QByteArray &kept_from = QByteArray()); - bool restore(QDomElement project, const QByteArray &database, QString *error = nullptr); + /// With @p lost, a damaged project being recovered: what does not + /// match is noted there and left out, instead of refusing it all + bool restore(QDomElement project, const QByteArray &database, QString *error = nullptr, + QStringList *lost = nullptr); + /// What only the database could fill, left in @p project after a + /// restore() with lost, taken out; said in @p lost + void dropLeftovers(QDomElement project, QStringList *lost); /// true if @p database is of the format this writes bool currentFormat(const QByteArray &database); } diff --git a/sources/container/qetzip.cpp b/sources/container/qetzip.cpp index dc9e2c9f7..edb780dab 100644 --- a/sources/container/qetzip.cpp +++ b/sources/container/qetzip.cpp @@ -160,9 +160,12 @@ bool QetZip::write(const QString &path, const QList &entries, QString *er Read every file entry of the zip @p zip into @p entries, in archive order. Folders are skipped. An encrypted entry, an unsafe or repeated name, a method miniz cannot read, or more than MaxEntries entries or - MaxUncompressedSize bytes refuse the whole archive. + MaxUncompressedSize bytes refuse the whole archive. With @p skipped (a + damaged project recovered), an entry that cannot be read is named + there and left out instead; the size limits still refuse it all. */ -bool QetZip::fromBytes(const QByteArray &zip_bytes, QList *entries, QString *error) +bool QetZip::fromBytes(const QByteArray &zip_bytes, QList *entries, QString *error, + QStringList *skipped) { if (!entries) return false; entries->clear(); @@ -188,6 +191,10 @@ bool QetZip::fromBytes(const QByteArray &zip_bytes, QList *entries, QStri { mz_zip_archive_file_stat stat; if (!mz_zip_reader_file_stat(&zip, i, &stat)) { + if (skipped) { + skipped->append(QStringLiteral("unreadable entry %1").arg(i)); + continue; + } setError(error, QStringLiteral("unreadable entry %1").arg(i)); mz_zip_reader_end(&zip); return false; @@ -197,6 +204,10 @@ bool QetZip::fromBytes(const QByteArray &zip_bytes, QList *entries, QStri const QString name = QString::fromUtf8(stat.m_filename); if (stat.m_is_encrypted || !stat.m_is_supported || !safeName(name) || names.contains(name)) { + if (skipped) { + skipped->append(QStringLiteral("entry \"%1\" cannot be read").arg(name)); + continue; + } setError(error, QStringLiteral("entry \"%1\" cannot be read").arg(name)); mz_zip_reader_end(&zip); return false; @@ -211,8 +222,13 @@ bool QetZip::fromBytes(const QByteArray &zip_bytes, QList *entries, QStri size_t size = 0; void *data = mz_zip_reader_extract_to_heap(&zip, i, &size, 0); if (!data) { - setError(error, QStringLiteral("cannot unpack \"%1\": %2") - .arg(name, QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(&zip))))); + const QString problem = QStringLiteral("cannot unpack \"%1\": %2") + .arg(name, QString::fromLatin1(mz_zip_get_error_string(mz_zip_get_last_error(&zip)))); + if (skipped) { + skipped->append(problem); + continue; + } + setError(error, problem); mz_zip_reader_end(&zip); return false; } diff --git a/sources/container/qetzip.h b/sources/container/qetzip.h index 808a861ce..28a423bd7 100644 --- a/sources/container/qetzip.h +++ b/sources/container/qetzip.h @@ -21,6 +21,7 @@ #include #include #include +#include /** @brief The QetZip namespace @@ -49,7 +50,8 @@ namespace QetZip bool write(const QString &path, const QList &entries, QString *error = nullptr); QByteArray toBytes(const QList &entries, QString *error = nullptr); bool read(const QString &path, QList *entries, QString *error = nullptr); - bool fromBytes(const QByteArray &zip, QList *entries, QString *error = nullptr); + bool fromBytes(const QByteArray &zip, QList *entries, QString *error = nullptr, + QStringList *skipped = nullptr); } #endif // QETZIP_H diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 4c1c020dd..541c0ee70 100644 --- a/tests/qttest/tst_qetcontainer.cpp +++ b/tests/qttest/tst_qetcontainer.cpp @@ -3,6 +3,7 @@ #include #include +#include #include @@ -764,6 +765,119 @@ private slots: QString error; QVERIFY(!QetContainer::join({{QStringLiteral("mimetype"), "application/zip", false}}, &back, &error)); } + + // recover() reads what it can of a damaged .qetz: each kind of damage + // costs what it held and nothing else, and is said + void recoverLeavesOutOnlyTheDamage_data() + { + QTest::addColumn("damage"); + QTest::addColumn("said"); + QTest::addColumn("folios"); + QTest::addColumn("wires"); + QTest::newRow("nothing") << QString() << QString() << 0 << 0; + QTest::newRow("folio file missing") << QStringLiteral("folio") << QStringLiteral("is missing") << -1 << 0; + QTest::newRow("wire row deleted") << QStringLiteral("row") << QStringLiteral("not in the database") << 0 << -1; + QTest::newRow("database page overwritten") << QStringLiteral("page") << QStringLiteral("cannot be read") << 0 << -1000; + } + void recoverLeavesOutOnlyTheDamage() + { + QFETCH(QString, damage); + QFETCH(QString, said); + QFETCH(int, folios); + QFETCH(int, wires); + QDomDocument original; + QList entries = splitFile(QStringLiteral(QET_EXAMPLES_DIR "/industrial.qet"), &original); + QVERIFY(!entries.isEmpty()); + auto count = [](const QDomDocument &d, const QString &tag) { + return int(d.documentElement().elementsByTagName(tag).size()); + }; + const int folios_before = count(original, QStringLiteral("diagram")); + const int wires_before = count(original, QStringLiteral("conductor")); + if (damage == QLatin1String("folio")) { + for (int i = 0 ; i < entries.size() ; ++i) + if (entries.at(i).name.startsWith(QStringLiteral("folios/"))) { entries.removeAt(i); break; } + } else if (damage == QLatin1String("row")) { + alterDatabase(entries, "DELETE FROM conductor WHERE rowid = (SELECT min(rowid) FROM conductor)"); + } else if (damage == QLatin1String("page")) { + for (QetZip::Entry &entry : entries) { + if (entry.name != QLatin1String("project.sqlite")) continue; + const int page_size = qFromBigEndian(entry.data.constData() + 16); + const int page = int(entry.data.size() / page_size / 2); + entry.data.replace(page * page_size, page_size, QByteArray(page_size, char(0xff))); + } + } + QByteArray zip; + QString error; + QVERIFY2(QetZip::toBytes(entries, &error).size() > 0, qPrintable(error)); + zip = QetZip::toBytes(entries, &error); + + QDomDocument back; + QStringList lost; + QVERIFY2(QetContainer::recover(zip, &back, &lost, &error), qPrintable(error)); + if (said.isEmpty()) { + QVERIFY2(lost.isEmpty(), qPrintable(lost.join(QLatin1Char('\n')))); + } else { + QVERIFY2(lost.join(QLatin1Char('\n')).contains(said), qPrintable(lost.join(QLatin1Char('\n')))); + // Strict reading refuses it + QDomDocument strict; + QVERIFY(!QetContainer::join(entries, &strict)); + } + QCOMPARE(count(back, QStringLiteral("diagram")), folios_before + folios); + if (wires == -1000) + QCOMPARE(count(back, QStringLiteral("conductor")), 0); + else + QCOMPARE(count(back, QStringLiteral("conductor")), wires_before + wires); + // Nothing of the container's own left in the document + QVERIFY(!back.toString().contains(QStringLiteral("qetz-"))); + } + + // A picture's file missing (F118 wrote such projects): the picture is + // left out, the rest read + void recoverMissingPicture() + { + QList entries; + QString error; + QVERIFY2(QetZip::fromBytes(read(QStringLiteral(QET_FIXTURES_DIR "/qetz_format2.qetz")), &entries, &error), + qPrintable(error)); + QDomDocument document; + QVERIFY2(QetContainer::join(entries, &document, &error), qPrintable(error)); + // A picture on the first folio, then saved and its file dropped + QDomElement folio = document.documentElement().firstChildElement(QStringLiteral("diagram")); + QDomElement images = folio.firstChildElement(QStringLiteral("images")); + if (images.isNull()) images = folio.appendChild(document.createElement(QStringLiteral("images"))).toElement(); + QDomElement image = document.createElement(QStringLiteral("image")); + image.setAttribute(QStringLiteral("type"), QStringLiteral("png")); + image.appendChild(document.createTextNode(QStringLiteral( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="))); + images.appendChild(image); + QList saved = QetContainer::split(document, QString(), &error); + QVERIFY2(!saved.isEmpty(), qPrintable(error)); + bool dropped = false; + for (int i = 0 ; i < saved.size() ; ++i) + if (saved.at(i).name.startsWith(QStringLiteral("images/"))) { saved.removeAt(i); dropped = true; break; } + QVERIFY(dropped); + QDomDocument strict; + QVERIFY(!QetContainer::join(saved, &strict, &error)); + QVERIFY(error.contains(QStringLiteral("picture"))); + + QDomDocument back; + QStringList lost; + QVERIFY2(QetContainer::recover(QetZip::toBytes(saved), &back, &lost, &error), qPrintable(error)); + QCOMPARE(lost.size(), 1); + QVERIFY(lost.first().contains(QStringLiteral("picture"))); + QCOMPARE(int(back.documentElement().elementsByTagName(QStringLiteral("image")).size()), 0); + QVERIFY(back.toString().contains(QStringLiteral(" Date: Fri, 16 Oct 2026 10:39:29 +1300 Subject: [PATCH 6/7] Offer a damaged project's previous version, or what can be read of it A project file that could not be read showed "The file ... does not appear to be a QElectroTech project file", for a .qetz missing one picture as for a picture file renamed .qet: wrong, and no way out. Now the message says what is damaged (QETProject::openError()) and offers, when there are any: - the newest previous version kept beside it (PreviousVersions); - for a .qetz, what can be read of it (QetContainer::recover()), then a list of what was left out. Either opens as a new project, " (recovered)" beside the file, numbered if that is taken, not saved yet. The damaged file is never moved or written. Checked in the program (scripts/qet-gui-dialog.sh, File > Open on a .qetz whose picture is missing, a previous version beside it): the dialog says which picture; "Recover what can be read" opens plant (recovered).qetz without the picture, saved with Ctrl+S it opens again; "Open the version saved ..." opens plant (recovered 2).qetz with its picture; plant.qetz unchanged (same sha256). ctest 107/107. Stacked on "Keep the previous versions of a project when saving it" and "Add recovering what can be read of a damaged .qetz". Co-Authored-By: Claude Opus 5.5 --- sources/qetdiagrameditor.cpp | 126 ++++++++++++++++++++++++++++++++++- sources/qetdiagrameditor.h | 3 + sources/qetproject.cpp | 11 +++ sources/qetproject.h | 3 + 4 files changed, 142 insertions(+), 1 deletion(-) diff --git a/sources/qetdiagrameditor.cpp b/sources/qetdiagrameditor.cpp index e582ac26f..a646f942a 100644 --- a/sources/qetdiagrameditor.cpp +++ b/sources/qetdiagrameditor.cpp @@ -16,6 +16,9 @@ along with QElectroTech. If not, see . */ #include "qetdiagrameditor.h" +#include "container/qetcontainer.h" +#include "container/qetzip.h" +#include "utils/previousversions.h" #include "shownkinds.h" #ifdef QET_HAS_SCRIPTING #include "scripting/qetscripting.h" @@ -25,6 +28,8 @@ #include "scripting/macrorecorder.h" #include "scripting/assistantinfo.h" #endif +#include +#include #include #include #include @@ -1888,6 +1893,115 @@ bool QETDiagramEditor::closeProject(QETProject *project) return(true); } +/** + @brief QETDiagramEditor::offerToRecover + @p filepath could not be read (@p reason): say why, and offer its newest + previous version (PreviousVersions) and, for a .qetz, what can be read + of it (QetContainer::recover()). Either opens as a new project, + " (recovered)"; @p filepath is never moved or written. + @return true if a project was opened +*/ +bool QETDiagramEditor::offerToRecover(const QString &filepath, const QString &reason) +{ + const QFileInfo info(filepath); + QFile file(filepath); + const bool zip = file.open(QIODevice::ReadOnly) && QetContainer::isZip(file.peek(4)); + file.close(); + const QStringList versions = PreviousVersions::kept(filepath); + + QMessageBox box(QMessageBox::Warning, tr("Cannot open %1").arg(info.fileName()), + reason.isEmpty() + ? tr("The file %1 cannot be read as a QElectroTech project.").arg(info.fileName()) + : tr("The project file is damaged:\n%1").arg(reason), + QMessageBox::Cancel, this); + QAbstractButton *previous = nullptr, *recover = nullptr; + if (!versions.isEmpty()) { + const QDateTime saved = QFileInfo(versions.first()).lastModified(); + previous = box.addButton(tr("Open the version saved %1") + .arg(QLocale().toString(saved, QLocale::ShortFormat)), + QMessageBox::AcceptRole); + } + if (zip) + recover = box.addButton(tr("Recover what can be read"), QMessageBox::AcceptRole); + if (previous || recover) + box.setInformativeText(tr("Either opens as a new project, \"%1\"; %2 is left as it is.") + .arg(recoveredPath(filepath, info.suffix()).section(QLatin1Char('/'), -1), + info.fileName())); + box.exec(); + + if (previous && box.clickedButton() == previous) + return openAsRecovered(versions.first(), filepath); + if (recover && box.clickedButton() == recover) { + file.open(QIODevice::ReadOnly); + QDomDocument document; + QStringList lost; + QString error; + if (!QetContainer::recover(file.readAll(), &document, &lost, &error)) { + QET::QetMessageBox::warning(this, tr("Cannot open %1").arg(info.fileName()), + tr("Nothing can be recovered from it:\n%1").arg(error)); + return false; + } + //Opened from a .qetz written of it, as the project it replaces + QTemporaryDir dir; + const QString read = dir.filePath(info.completeBaseName() + QStringLiteral(".qetz")); + const QList parts = QetContainer::split(document, QString(), &error); + if (!dir.isValid() || parts.isEmpty() || !QetZip::write(read, parts, &error) + || !openAsRecovered(read, filepath)) { + if (!error.isEmpty()) + QET::QetMessageBox::warning(this, tr("Cannot open %1").arg(info.fileName()), error); + return false; + } + if (!lost.isEmpty()) { + QMessageBox done(QMessageBox::Information, tr("Project recovered"), + tr("What could be read of %1 is open as a new project. " + "Some parts could not be read and were left out: see the details.") + .arg(info.fileName()), + QMessageBox::Ok, this); + done.setDetailedText(lost.join(QLatin1Char('\n'))); + done.exec(); + } + return true; + } + return false; +} + +/** + @brief QETDiagramEditor::recoveredPath + @return where a project recovered from @p filepath is saved: beside it, + " (recovered).", numbered if that is taken +*/ +QString QETDiagramEditor::recoveredPath(const QString &filepath, const QString &suffix) +{ + const QFileInfo info(filepath); + QString path = info.absoluteDir().filePath(tr("%1 (recovered)").arg(info.completeBaseName()) + + QLatin1Char('.') + suffix); + for (int n = 2 ; QFileInfo::exists(path) ; ++n) + path = info.absoluteDir().filePath(tr("%1 (recovered %2)").arg(info.completeBaseName()).arg(n) + + QLatin1Char('.') + suffix); + return path; +} + +/** + @brief QETDiagramEditor::openAsRecovered + Open @p source as a new project saved beside @p original, of its type + (recoveredPath()), not saved yet. + @return true if it opened +*/ +bool QETDiagramEditor::openAsRecovered(const QString &source, const QString &original) +{ + QETProject *project = new QETProject(source); + if (project->state() != QETProject::Ok) { + QET::QetMessageBox::warning(this, tr("Cannot open %1").arg(QFileInfo(source).fileName()), + project->openError()); + delete project; + return false; + } + project->setFilePath(recoveredPath(original, QFileInfo(original).suffix())); + project->setModified(true); + addProject(project); + return true; +} + /** Ouvre un projet depuis un fichier et l'ajoute a cet editeur @param filepath Chemin du projet a ouvrir @@ -1979,7 +2093,17 @@ bool QETDiagramEditor::openAndAddProject( QETProject *project = new QETProject(filepath); if (project -> state() != QETProject::Ok) { - if (interactive && project -> state() != QETProject::FileOpenDiscard) + //A damaged project: its previous version or what can be read + //of it, as a new project; the file itself is left as it is + if (interactive && project->state() == QETProject::XmlParsingFailed + && offerToRecover(filepath, project->openError())) + { + delete project; + DialogWaiting::dropInstance(); + return(true); + } + if (interactive && project -> state() != QETProject::FileOpenDiscard + && project -> state() != QETProject::XmlParsingFailed) { QET::QetMessageBox::warning( this, diff --git a/sources/qetdiagrameditor.h b/sources/qetdiagrameditor.h index c8ff27793..41a8a365f 100644 --- a/sources/qetdiagrameditor.h +++ b/sources/qetdiagrameditor.h @@ -86,6 +86,9 @@ class QETDiagramEditor : public QETMainWindow protected: bool event(QEvent *) override; private: + bool offerToRecover(const QString &filepath, const QString &reason); + bool openAsRecovered(const QString &source, const QString &original); + static QString recoveredPath(const QString &filepath, const QString &suffix); // Declared first so it is initialised before any member whose // constructor may dispatch a Qt event calling event() (e.g. the // QActionGroup members below trigger QObject::setParent events). diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 8f04cca20..33f164bca 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -515,6 +515,7 @@ QETProject::ProjectState QETProject::openFile(QFile *file) if (!read) { qWarning().noquote() << "Unable to read" << fi.fileName() << ":" << read_error; + m_open_error = read_error; m_container_pictures.clear(); m_folio_parts.clear(); if(opened_here) { @@ -603,6 +604,16 @@ QETProject::ProjectState QETProject::state() const return(m_state); } +/** + @brief QETProject::openError + @return why the file could not be read, when state() is + XmlParsingFailed: "picture ... is missing", say; empty otherwise +*/ +QString QETProject::openError() const +{ + return m_open_error; +} + /** @return la liste des schemas de ce projet */ diff --git a/sources/qetproject.h b/sources/qetproject.h index e2c7f0f9d..05603ca42 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -123,6 +123,7 @@ class QETProject : public QObject QUuid derivedItemUuid(const QString &kind, const QString &key); QByteArray containerPicture(const QString &file) const; ProjectState state() const; + QString openError() const; QList diagrams() const; //The folios whether their items are built or not //(lazy folios); diagrams() builds them @@ -584,6 +585,8 @@ class QETProject : public QObject QFuture m_backup_future; /// Crash-recovery snapshots, written in turn by writeBackup() std::array m_backup_files; + /// Why the file could not be read, see openError() + QString m_open_error; int m_next_backup_slot = 0; QUuid m_uuid = QUuid::createUuid(); QHash m_derived_uuid_keys; From b4bbd17933480fcbaaee6601bc5d6ce2b3ec25eb Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 09:32:13 +1300 Subject: [PATCH 7/7] Fix a .qetz that cannot be opened after only a folio's title changed With a .qetz and lazy folios (both the default), changing only the title, author, date or revision of a folio holding a picture, while that folio was not displayed, then saving, gave a .qetz that refused to open: 'picture "images/.png" is missing'. Such a folio is saved from its items as read, with its new header (Diagram::deferredXmlWithHeader()). Read from a .qetz, its names its picture's file (qetz-file) instead of holding the picture, and QetContainer::split() takes out only pictures held inline: the file was never written, while the folio still named it. The folio now gets its pictures written in, from the bytes the project keeps (QETProject::containerPicture()), as a built folio writes them. On master, a picture on folio 2 of grafcet, iso_sfc_example, 741, tremie_vibrante or Projet_vierge is lost this way; found on three forum projects by comparing lazy saves with saves of every folio built. Saving as .qet was not affected. New tst_qetzheaderpictures (grafcet.qet: picture on folio 2, saved as .qetz; its title changed with folios built when needed; reopened with its picture and title): fails without this change ("picture ... is missing"). ctest 107/107. Co-Authored-By: Claude Opus 5.5 --- sources/container/qetcontainer.cpp | 7 ++ sources/container/qetcontainer.h | 3 + sources/diagram.cpp | 11 +++ tests/qttest/CMakeLists.txt | 12 ++++ tests/qttest/tst_qetzheaderpictures.cpp | 90 +++++++++++++++++++++++++ 5 files changed, 123 insertions(+) create mode 100644 tests/qttest/tst_qetzheaderpictures.cpp diff --git a/sources/container/qetcontainer.cpp b/sources/container/qetcontainer.cpp index 95f306c0e..19a7615b6 100644 --- a/sources/container/qetcontainer.cpp +++ b/sources/container/qetcontainer.cpp @@ -208,6 +208,13 @@ QString QetContainer::pictureFile(const QDomElement &image) return image.attribute(ImageFileAttribute); } +void QetContainer::inlinePicture(QDomElement image, const QByteArray &bytes) +{ + image.removeAttribute(ImageFileAttribute); + image.insertBefore(image.ownerDocument().createTextNode(QString::fromLatin1(bytes.toBase64())), + image.firstChild()); +} + /** @brief QetContainer::ownUuids @return true if every folio, symbol and wire of @p project has a uuid diff --git a/sources/container/qetcontainer.h b/sources/container/qetcontainer.h index 7447fafc3..bdfd37c55 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -84,6 +84,9 @@ namespace QetContainer QString *error = nullptr); /// The file join() left the picture of @p image in, or an empty string QString pictureFile(const QDomElement &image); + /// @p image, which names its picture's file (pictureFile()), given + /// the picture itself, @p bytes, as a document read whole has it + void inlinePicture(QDomElement image, const QByteArray &bytes); /// Take what QElectroTech computes out of a folio (format 4) void dropComputed(QDomElement diagram, bool terminal_lists); diff --git a/sources/diagram.cpp b/sources/diagram.cpp index b3eae505e..5dc44f471 100644 --- a/sources/diagram.cpp +++ b/sources/diagram.cpp @@ -881,6 +881,17 @@ QDomElement Diagram::deferredXmlWithHeader(QDomDocument &document) for (QDomElement child = items.firstChildElement() ; !child.isNull() ; child = child.nextSiblingElement()) if (!header_children.contains(child.tagName())) folio.appendChild(document.importNode(child, true)); + //Its pictures written in, as building it writes them. A folio read + //from a .qetz names its pictures' files instead, which the next + //.qetz written from this document would leave out: that project + //then refused to open, "picture ... is missing" (F118). + for (QDomElement image : QET::findInDomElement(folio, QStringLiteral("images"), QStringLiteral("image"))) { + const QString file = QetContainer::pictureFile(image); + if (file.isEmpty() || !m_project) continue; + const QByteArray bytes = m_project->containerPicture(file); + if (!bytes.isEmpty()) + QetContainer::inlinePicture(image, bytes); + } return folio; } diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index f271bce96..9b589d403 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -943,6 +943,18 @@ if(QET_HAS_SCRIPTING) "QET_TEST_BINARY_PATH=\"$\"" "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # A .qetz folio whose title only changed keeps its pictures when saved + # with folios built when needed (F118), on examples/grafcet.qet. + add_executable( + tst_qetzheaderpictures + tst_qetzheaderpictures.cpp) + add_test(NAME tst_qetzheaderpictures COMMAND tst_qetzheaderpictures) + add_dependencies(tst_qetzheaderpictures qelectrotech) + target_link_libraries(tst_qetzheaderpictures PRIVATE Qt::Test) + target_compile_definitions(tst_qetzheaderpictures PRIVATE + "QET_TEST_BINARY_PATH=\"$\"" + "QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"") + # qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex() # turns element uuid + terminal uuid back into the index the conductor # calls take, on examples/perceuse.qet (two terminals at one point). diff --git a/tests/qttest/tst_qetzheaderpictures.cpp b/tests/qttest/tst_qetzheaderpictures.cpp new file mode 100644 index 000000000..49bd4013f --- /dev/null +++ b/tests/qttest/tst_qetzheaderpictures.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include +#include +#include +#include +#include + +// A folio of a .qetz whose title only changed is saved from its items as +// read, not built (Diagram::deferredXmlWithHeader()). Those items name +// their pictures' files in the .qetz, which the new .qetz left out: the +// project saved then refused to open, 'picture "images/..." is missing' +// (FINDINGS F118). Runs scripts through the real binary's --run on a copy +// of examples/grafcet.qet given a picture on its second folio. +class tst_qetzheaderpictures : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // Run @p script on @p project; its output + QString run(const QString &script, const QString &project, bool every_folio_built) + { + const QString path = m_dir.filePath(QStringLiteral("script.js")); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) return QString(); + f.write(script.toUtf8()); + f.close(); + const QString home = m_dir.filePath(QStringLiteral("home")); + QDir().mkpath(home); + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("QT_QPA_PLATFORM"), QStringLiteral("offscreen")); + env.insert(QStringLiteral("QET_ENABLE_SCRIPTING"), QStringLiteral("1")); + env.insert(QStringLiteral("HOME"), home); + env.insert(QStringLiteral("XDG_CONFIG_HOME"), home + QStringLiteral("/config")); + env.insert(QStringLiteral("XDG_DATA_HOME"), home + QStringLiteral("/data")); + env.insert(QStringLiteral("TMPDIR"), m_dir.path()); + if (every_folio_built) + env.insert(QStringLiteral("QET_EAGER_FOLIOS"), QStringLiteral("1")); + else + env.remove(QStringLiteral("QET_EAGER_FOLIOS")); + QProcess proc; + proc.setProcessEnvironment(env); + proc.start(QStringLiteral(QET_TEST_BINARY_PATH), {QStringLiteral("--run"), path, project}); + proc.waitForFinished(120000); + return QString::fromUtf8(proc.readAllStandardOutput() + proc.readAllStandardError()); + } + +private slots: + void initTestCase() + { + QVERIFY(m_dir.isValid()); + QVERIFY(QFile::exists(QStringLiteral(QET_TEST_BINARY_PATH))); + } + + void titleChangeKeepsPictures() + { + // A 1x1 PNG + const QString png = m_dir.filePath(QStringLiteral("dot.png")); + QFile p(png); + QVERIFY(p.open(QIODevice::WriteOnly)); + p.write(QByteArray::fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")); + p.close(); + const QString with_picture = m_dir.filePath(QStringLiteral("picture.qetz")); + const QString titled = m_dir.filePath(QStringLiteral("titled.qetz")); + QString out = run(QStringLiteral("qet.log('ADDED ' + qet.addImage(1, '%1', 100, 100));" + "qet.save('%2');").arg(png, with_picture), + QStringLiteral(QET_EXAMPLES_DIR "/grafcet.qet"), true); + QVERIFY2(out.contains(QStringLiteral("ADDED 0")), qPrintable(out)); + QVERIFY(QFile::exists(with_picture)); + + // Only the title of the folio with the picture, folios built + // when needed + out = run(QStringLiteral("qet.log('SET ' + qet.setFolioProperty(1, 'title', 'Edited'));" + "qet.save('%1');").arg(titled), with_picture, false); + QVERIFY2(out.contains(QStringLiteral("SET true")), qPrintable(out)); + QVERIFY(QFile::exists(titled)); + + // Opens, with its picture and the new title + out = run(QStringLiteral("qet.log('PICTURES ' + qet.images(1).length);" + "qet.log('TITLE ' + qet.folioTitle(1));"), titled, true); + QVERIFY2(out.contains(QStringLiteral("PICTURES 1")), qPrintable(out)); + QVERIFY2(out.contains(QStringLiteral("TITLE Edited")), qPrintable(out)); + } +}; + +QTEST_APPLESS_MAIN(tst_qetzheaderpictures) + +#include "tst_qetzheaderpictures.moc"