diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index fcedf5817..d80416c1e 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -940,7 +940,10 @@ 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/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..89e5ceb9f 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)) { @@ -885,14 +923,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 +1017,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"; @@ -1042,6 +1076,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..19a7615b6 100644 --- a/sources/container/qetcontainer.cpp +++ b/sources/container/qetcontainer.cpp @@ -184,11 +184,37 @@ 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); } +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 @@ -421,23 +447,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 +498,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 +538,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..bdfd37c55 100644 --- a/sources/container/qetcontainer.h +++ b/sources/container/qetcontainer.h @@ -77,9 +77,16 @@ 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); + /// @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/container/qetcontainerdb.cpp b/sources/container/qetcontainerdb.cpp index aaab24a38..8302e01fc 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}); @@ -749,8 +776,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 +800,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 +819,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 +869,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); @@ -902,7 +956,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}); @@ -1135,12 +1190,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 +1213,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 +1232,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..47410f099 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; } @@ -160,9 +162,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 +193,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 +206,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 +224,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/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/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/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/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/sources/qetproject.cpp b/sources/qetproject.cpp index 56971cf4b..70061cbed 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 @@ -521,6 +522,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) { @@ -609,6 +611,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 */ @@ -2832,6 +2844,13 @@ QETResult QETProject::write(bool overwrite_changed) .arg(QLocale::system().toString(when, QLocale::ShortFormat)))); } + //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/qetproject.h b/sources/qetproject.h index f2c021b86..42aef41fb 100644 --- a/sources/qetproject.h +++ b/sources/qetproject.h @@ -124,6 +124,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 @@ -593,6 +594,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; 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/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 diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 535842a96..fdf34309e 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -931,6 +931,30 @@ 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\"") + + # 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\"") + # Saving refuses a project file another user or program changed since it was # opened (QETProject::changedOnDisk()); QET_OVERWRITE_CHANGED=1 overrides. add_executable( @@ -1276,6 +1300,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" diff --git a/tests/qttest/tst_qetcontainer.cpp b/tests/qttest/tst_qetcontainer.cpp index 4c1c020dd..f6ca2bc8d 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) { @@ -764,6 +797,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(" + +#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" 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"