From 2bd19e04be4ca02df534b26d01be33430a725c61 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Fri, 16 Oct 2026 10:15:37 +1300 Subject: [PATCH] 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("