Merge master into feat/changed-on-disk-check

Conflicts: QETProject::write() now checks for a change on disk before it
keeps the previous version; both new tests kept in tests/qttest/CMakeLists.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
This commit is contained in:
ispyisail
2026-10-16 17:44:42 +13:00
26 changed files with 1213 additions and 70 deletions
+56 -12
View File
@@ -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<QString, QString> &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<int, QByteArray> 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<int, QByteArray> 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 <damaged.qetz> <output.qet>\n";
return 2;
}
return recoverProject(rest.at(0), rest.at(1));
}
const QString input = rest.value(0);
if (input.isEmpty()) {
err << "Usage: qelectrotech " << flag << " <project.qet> <output>\n";
+1
View File
@@ -55,6 +55,7 @@ namespace CLIExport {
qelectrotech --info <project.qet> [output.json]
qelectrotech --check-elements <element.elmt | directory>
qelectrotech --resave <project.qet> <output.qet>
qelectrotech --recover <damaged.qetz> <output.qet>
qelectrotech --set-titleblock <project.qet> <output.qet> key=value...
PDF: one multi-page document (one diagram per page).
+63 -16
View File
@@ -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<QetZip::Entry> 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<QetZip::Entry> QetContainer::split(const QDomDocument &project, const QStr
for a later one this may read (its min-reader allows it).
*/
bool QetContainer::join(const QList<QetZip::Entry> &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<QString, QByteArray> 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<QetZip::Entry> &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<QDomElement> 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<QetZip::Entry> &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;
}
+8 -1
View File
@@ -77,9 +77,16 @@ namespace QetContainer
const QHash<QString, QByteArray> &kept_from = QHash<QString, QByteArray>());
bool join(const QList<QetZip::Entry> &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);
+126 -34
View File
@@ -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
<project> 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<QString, QVariant> 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<QDomElement> markers, wires;
QList<QDomElement> 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()));
}
/**
+8 -1
View File
@@ -20,6 +20,7 @@
#include <QByteArray>
#include <QDomElement>
#include <QStringList>
/**
@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);
}
+22 -4
View File
@@ -18,6 +18,7 @@
#include "qetzip.h"
#include "miniz/miniz.h"
#include "../utils/syncfolder.h"
#include <QFile>
#include <QSaveFile>
@@ -152,6 +153,7 @@ bool QetZip::write(const QString &path, const QList<Entry> &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<Entry> &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<Entry> *entries, QString *error)
bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *entries, QString *error,
QStringList *skipped)
{
if (!entries) return false;
entries->clear();
@@ -188,6 +193,10 @@ bool QetZip::fromBytes(const QByteArray &zip_bytes, QList<Entry> *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<Entry> *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<Entry> *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;
}
+3 -1
View File
@@ -21,6 +21,7 @@
#include <QByteArray>
#include <QList>
#include <QString>
#include <QStringList>
/**
@brief The QetZip namespace
@@ -49,7 +50,8 @@ namespace QetZip
bool write(const QString &path, const QList<Entry> &entries, QString *error = nullptr);
QByteArray toBytes(const QList<Entry> &entries, QString *error = nullptr);
bool read(const QString &path, QList<Entry> *entries, QString *error = nullptr);
bool fromBytes(const QByteArray &zip, QList<Entry> *entries, QString *error = nullptr);
bool fromBytes(const QByteArray &zip, QList<Entry> *entries, QString *error = nullptr,
QStringList *skipped = nullptr);
}
#endif // QETZIP_H
+11
View File
@@ -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;
}
+2
View File
@@ -19,6 +19,7 @@
#include "qetapp.h"
#include "qeticons.h"
#include "shortcutmanager.h"
#include "utils/syncfolder.h"
#include <cmath>
#include <limits>
@@ -809,6 +810,7 @@ bool QET::writeXmlText(const QString &text, const QString &filepath, QString *er
return false;
}
syncFolderOf(filepath);
return(true);
}
+125 -1
View File
@@ -16,6 +16,9 @@
along with QElectroTech. If not, see <http://www.gnu.org/licenses/>.
*/
#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 <QDomDocument>
#include <QTemporaryDir>
#include <QCoreApplication>
#include <QToolButton>
#include <QWidgetAction>
@@ -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,
"<name> (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<QetZip::Entry> 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,
"<name> (recovered).<suffix>", 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,
+3
View File
@@ -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).
@@ -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) {
+19
View File
@@ -48,6 +48,7 @@
#include "qetinformation.h"
#include "qetversion.h"
#include "undocommand/adddiagramcommand.h"
#include "utils/previousversions.h"
#include <QCryptographicHash>
#include <QElapsedTimer>
@@ -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))
{
+3
View File
@@ -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<Diagram *> 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<bool> m_backup_future;
/// Crash-recovery snapshots, written in turn by writeBackup()
std::array<KAutoSaveFile, BackupGenerations> 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<QString, int> m_derived_uuid_keys;
@@ -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());
@@ -365,6 +365,29 @@
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="m_previous_versions_label">
<property name="text">
<string>Previous versions kept when saving a project</string>
</property>
<property name="toolTip">
<string>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.</string>
</property>
</widget>
</item>
<item row="7" column="2">
<widget class="QSpinBox" name="m_previous_versions_sb">
<property name="toolTip">
<string>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.</string>
</property>
<property name="specialValueText">
<string>None</string>
</property>
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
<item row="8" column="0">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
@@ -1534,6 +1557,7 @@
<tabstop>m_use_folio_label</tabstop>
<tabstop>m_border_0</tabstop>
<tabstop>m_autosave_sb</tabstop>
<tabstop>m_previous_versions_sb</tabstop>
<tabstop>m_common_elmt_path_cb</tabstop>
<tabstop>m_custom_elmt_path_cb</tabstop>
<tabstop>m_custom_tbt_path_cb</tabstop>
+113
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "previousversions.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QSettings>
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;
}
+49
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef PREVIOUSVERSIONS_H
#define PREVIOUSVERSIONS_H
#include <QString>
#include <QStringList>
/**
@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, "<name>-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
+54
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#ifndef SYNCFOLDER_H
#define SYNCFOLDER_H
#include <QFile>
#include <QFileInfo>
#include <QString>
#ifndef Q_OS_WIN
#include <fcntl.h>
#include <unistd.h>
#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