mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-17 19:34:13 +02:00
Merge master into feat/project-lock
Conflicts: the lock dialog and the recovery dialog both kept in qetdiagrameditor.cpp; the lock and previous-versions settings both kept. The spacer in the General settings page moves to row 9, below the lock checkbox, which the merge had left on the same row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MgPFANhWgD66V3v9KVKV58
This commit is contained in:
@@ -942,7 +942,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
|
||||
|
||||
+56
-12
@@ -29,6 +29,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"
|
||||
@@ -86,6 +87,7 @@ const QHash<QString, QString> &exportFlags()
|
||||
{"--info", "info"},
|
||||
{"--check-elements", "check"},
|
||||
{"--resave", "resave"},
|
||||
{"--recover", "recover"},
|
||||
{"--set-titleblock", "settb"},
|
||||
};
|
||||
return flags;
|
||||
@@ -871,6 +873,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)) {
|
||||
@@ -886,14 +924,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;
|
||||
}
|
||||
@@ -981,14 +1018,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";
|
||||
@@ -1044,6 +1078,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";
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1933,6 +1938,115 @@ QETDiagramEditor::LockChoice QETDiagramEditor::askAboutLock(
|
||||
return LockChoice::Cancel;
|
||||
}
|
||||
|
||||
/**
|
||||
@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
|
||||
@@ -2058,7 +2172,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,
|
||||
|
||||
@@ -89,6 +89,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) {
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
#include "qetinformation.h"
|
||||
#include "qetversion.h"
|
||||
#include "undocommand/adddiagramcommand.h"
|
||||
#include "utils/previousversions.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QElapsedTimer>
|
||||
@@ -514,6 +515,7 @@ QETProject::ProjectState QETProject::openFile(QFile *file)
|
||||
if (!read)
|
||||
{
|
||||
qWarning().noquote() << "Unable to read" << fi.fileName() << ":" << read_error;
|
||||
m_open_error = read_error;
|
||||
m_container_pictures.clear();
|
||||
m_folio_parts.clear();
|
||||
if(opened_here) {
|
||||
@@ -602,6 +604,16 @@ QETProject::ProjectState QETProject::state() const
|
||||
return(m_state);
|
||||
}
|
||||
|
||||
/**
|
||||
@brief QETProject::openError
|
||||
@return why the file could not be read, when state() is
|
||||
XmlParsingFailed: "picture ... is missing", say; empty otherwise
|
||||
*/
|
||||
QString QETProject::openError() const
|
||||
{
|
||||
return m_open_error;
|
||||
}
|
||||
|
||||
/**
|
||||
@return la liste des schemas de ce projet
|
||||
*/
|
||||
@@ -2763,6 +2775,13 @@ QETResult QETProject::write()
|
||||
return(QString("the file %1 was opened read-only and thus will not be written").arg(m_file_path));
|
||||
}
|
||||
|
||||
//The file as it was goes among its previous versions first
|
||||
//(Settings > General > Projects); a copy that fails does not stop
|
||||
//the save
|
||||
QString backup_error;
|
||||
if (!PreviousVersions::keep(m_file_path, PreviousVersions::count(), &backup_error))
|
||||
qWarning().noquote() << "Previous version of" << m_file_path << "not kept:" << backup_error;
|
||||
|
||||
QString error_message;
|
||||
if (isContainerPath(m_file_path))
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -594,6 +595,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"
|
||||
@@ -191,6 +192,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) :
|
||||
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_project_lock->setChecked(settings.value("projectlock/enabled", true).toBool());
|
||||
ui->m_previous_versions_sb->setValue(PreviousVersions::count());
|
||||
|
||||
QString fontInfos = settings.value("diagramitemfont", "Liberation Sans").toString() + " " +
|
||||
settings.value("diagramitemsize", "9").toString() + " (" +
|
||||
@@ -419,6 +421,7 @@ void GeneralConfigurationPage::applyConf()
|
||||
}
|
||||
settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value());
|
||||
settings.setValue("projectlock/enabled", ui->m_project_lock->isChecked());
|
||||
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());
|
||||
|
||||
@@ -375,6 +375,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="9" column="0">
|
||||
<spacer name="verticalSpacer_2">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
@@ -1545,6 +1568,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -942,6 +942,30 @@ if(QET_HAS_SCRIPTING)
|
||||
"QET_TEST_BINARY_PATH=\"$<TARGET_FILE:qelectrotech>\""
|
||||
"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=\"$<TARGET_FILE:qelectrotech>\""
|
||||
"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=\"$<TARGET_FILE:qelectrotech>\""
|
||||
"QET_EXAMPLES_DIR=\"${QET_DIR}/examples\"")
|
||||
|
||||
# qet.elementTerminals() lists each terminal's uuid and qet.terminalIndex()
|
||||
# turns element uuid + terminal uuid back into the index the conductor
|
||||
# calls take, on examples/perceuse.qet (two terminals at one point).
|
||||
@@ -1275,6 +1299,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(
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include <QtTest>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QFile>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
#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 "<name>-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"
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QtEndian>
|
||||
|
||||
#include <functional>
|
||||
|
||||
@@ -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<double>("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<QetZip::Entry> 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<quint16>(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<QetZip::Entry> &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<QString>("damage");
|
||||
QTest::addColumn<QString>("said");
|
||||
QTest::addColumn<int>("folios");
|
||||
QTest::addColumn<int>("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<QetZip::Entry> 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<quint16>(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<QetZip::Entry> 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<QetZip::Entry> 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("<elementInformation ")));
|
||||
}
|
||||
|
||||
// What cannot be read as a zip at all is still refused
|
||||
void recoverNotAZipRefused()
|
||||
{
|
||||
QDomDocument back;
|
||||
QStringList lost;
|
||||
QString error;
|
||||
QVERIFY(!QetContainer::recover(QByteArray("not a zip").repeated(10), &back, &lost, &error));
|
||||
QVERIFY(!error.isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_APPLESS_MAIN(tst_qetcontainer)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include <QtTest>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
// 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"
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#include <QtTest>
|
||||
|
||||
#include <QDir>
|
||||
#include <QDomDocument>
|
||||
#include <QFile>
|
||||
#include <QProcess>
|
||||
#include <QProcessEnvironment>
|
||||
#include <QTemporaryDir>
|
||||
|
||||
// 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<bool>("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"
|
||||
Reference in New Issue
Block a user