diff --git a/cmake/qet_compilation_vars.cmake b/cmake/qet_compilation_vars.cmake index fcedf5817..1b4851052 100644 --- a/cmake/qet_compilation_vars.cmake +++ b/cmake/qet_compilation_vars.cmake @@ -940,6 +940,8 @@ set(QET_SRC_FILES ${QET_DIR}/sources/utils/qetsettings.cpp ${QET_DIR}/sources/utils/qetsettings.h ${QET_DIR}/sources/utils/qetutils.cpp + ${QET_DIR}/sources/utils/previousversions.cpp + ${QET_DIR}/sources/utils/previousversions.h ${QET_DIR}/sources/utils/qetutils.h ${QET_DIR}/sources/materiallist/materialentrydialog.cpp diff --git a/sources/qetproject.cpp b/sources/qetproject.cpp index 45ef03635..8f04cca20 100644 --- a/sources/qetproject.cpp +++ b/sources/qetproject.cpp @@ -48,6 +48,7 @@ #include "qetinformation.h" #include "qetversion.h" #include "undocommand/adddiagramcommand.h" +#include "utils/previousversions.h" #include #include @@ -2759,6 +2760,13 @@ QETResult QETProject::write() return(QString("the file %1 was opened read-only and thus will not be written").arg(m_file_path)); } + //The file as it was goes among its previous versions first + //(Settings > General > Projects); a copy that fails does not stop + //the save + QString backup_error; + if (!PreviousVersions::keep(m_file_path, PreviousVersions::count(), &backup_error)) + qWarning().noquote() << "Previous version of" << m_file_path << "not kept:" << backup_error; + QString error_message; if (isContainerPath(m_file_path)) { diff --git a/sources/ui/configpage/generalconfigurationpage.cpp b/sources/ui/configpage/generalconfigurationpage.cpp index ccbc24cdd..68ef91306 100644 --- a/sources/ui/configpage/generalconfigurationpage.cpp +++ b/sources/ui/configpage/generalconfigurationpage.cpp @@ -26,6 +26,7 @@ #include "../../cablelist/cabletypelist.h" #include "../../utils/qetsettings.h" #include "../../utils/qetutils.h" +#include "../../utils/previousversions.h" #include "../../qetmessagebox.h" #include "../../textgrid.h" #include "../../wiringrules.h" @@ -190,6 +191,7 @@ GeneralConfigurationPage::GeneralConfigurationPage(QWidget *parent) : ui->m_use_folio_label->setChecked(settings.value("genericpanel/folio", true).toBool()); ui->m_border_0->setChecked(settings.value("border-columns_0", false).toBool()); ui->m_autosave_sb->setValue(settings.value("diagrameditor/autosave-interval", 0).toInt()); + ui->m_previous_versions_sb->setValue(PreviousVersions::count()); QString fontInfos = settings.value("diagramitemfont", "Liberation Sans").toString() + " " + settings.value("diagramitemsize", "9").toString() + " (" + @@ -417,6 +419,7 @@ void GeneralConfigurationPage::applyConf() } } settings.setValue("diagrameditor/autosave-interval", ui->m_autosave_sb->value()); + PreviousVersions::setCount(ui->m_previous_versions_sb->value()); settings.setValue("diagrameditor/grid_display_startup", ui->grid_startup_cb->isChecked()); settings.setValue("diagrameditor/guides_display_startup", ui->guides_startup_cb->isChecked()); diff --git a/sources/ui/configpage/generalconfigurationpage.ui b/sources/ui/configpage/generalconfigurationpage.ui index b0f0d0d6e..88858fe8e 100644 --- a/sources/ui/configpage/generalconfigurationpage.ui +++ b/sources/ui/configpage/generalconfigurationpage.ui @@ -365,6 +365,29 @@ + + + Previous versions kept when saving a project + + + Before a project is saved, the file as it was is copied to a folder beside it, named after the project with "-backups". Only the newest are kept. + + + + + + + Before a project is saved, the file as it was is copied to a folder beside it, named after the project with "-backups". Only the newest are kept. + + + None + + + 100 + + + + Qt::Vertical @@ -1534,6 +1557,7 @@ m_use_folio_label m_border_0 m_autosave_sb + m_previous_versions_sb m_common_elmt_path_cb m_custom_elmt_path_cb m_custom_tbt_path_cb diff --git a/sources/utils/previousversions.cpp b/sources/utils/previousversions.cpp new file mode 100644 index 000000000..9d741d3d0 --- /dev/null +++ b/sources/utils/previousversions.cpp @@ -0,0 +1,113 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include "previousversions.h" + +#include +#include +#include +#include +#include +#include + +namespace { +const QString CountKey = QStringLiteral("diagrameditor/previous-versions"); +constexpr int DefaultCount = 5; + + //What a version of @p info is named, without the extension: its base + //name and a time +QString versionPrefix(const QFileInfo &info) +{ + return info.completeBaseName() + QLatin1Char(' '); +} + +QByteArray digest(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + hash.addData(&file); + return hash.result(); +} +} + +int PreviousVersions::count() +{ + return qMax(0, QSettings().value(CountKey, DefaultCount).toInt()); +} + +void PreviousVersions::setCount(int count) +{ + QSettings().setValue(CountKey, qMax(0, count)); +} + +QString PreviousVersions::folder(const QString &file_path) +{ + const QFileInfo info(file_path); + return info.absoluteDir().filePath(info.completeBaseName() + QStringLiteral("-backups")); +} + +QStringList PreviousVersions::kept(const QString &file_path) +{ + const QFileInfo info(file_path); + const QDir dir(folder(file_path)); + //Each copy has the time its version was saved (keep()); a .qet and + //a .qetz of the same project are versions of it alike + QStringList names = dir.entryList({versionPrefix(info) + QStringLiteral("????-??-?? ??-??-??*.qet"), + versionPrefix(info) + QStringLiteral("????-??-?? ??-??-??*.qetz")}, + QDir::Files, QDir::Time); + QStringList paths; + for (const QString &name : std::as_const(names)) + paths << dir.filePath(name); + return paths; +} + +bool PreviousVersions::keep(const QString &file_path, int count, QString *error) +{ + const QFileInfo info(file_path); + if (count <= 0 || !info.isFile()) return true; + + const QStringList versions = kept(file_path); + if (!versions.isEmpty() && QFileInfo(versions.first()).size() == info.size() + && digest(versions.first()) == digest(file_path)) + return true; + + QDir dir(folder(file_path)); + if (!dir.exists() && !QDir().mkpath(dir.path())) { + if (error) *error = QStringLiteral("cannot create the folder %1").arg(dir.path()); + return false; + } + //Named by the time this version was saved, made unique if another + //was saved the same second + const QDateTime saved = info.lastModified(); + const QString stem = versionPrefix(info) + saved.toString(QStringLiteral("yyyy-MM-dd HH-mm-ss")); + QString target = dir.filePath(stem + QLatin1Char('.') + info.suffix()); + for (int n = 2 ; QFileInfo::exists(target) ; ++n) + target = dir.filePath(stem + QStringLiteral(" (%1).").arg(n) + info.suffix()); + if (!QFile::copy(file_path, target)) { + if (error) *error = QStringLiteral("cannot copy %1 to %2").arg(file_path, target); + return false; + } + QFile copy(target); + if (copy.open(QIODevice::ReadWrite)) + copy.setFileTime(saved, QFileDevice::FileModificationTime); + + const QStringList all = kept(file_path); + for (int i = count ; i < all.size() ; ++i) + QFile::remove(all.at(i)); + return true; +} diff --git a/sources/utils/previousversions.h b/sources/utils/previousversions.h new file mode 100644 index 000000000..16844eaad --- /dev/null +++ b/sources/utils/previousversions.h @@ -0,0 +1,49 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#ifndef PREVIOUSVERSIONS_H +#define PREVIOUSVERSIONS_H + +#include +#include + +/** + @brief The PreviousVersions namespace + The versions of a project file kept before it is saved over, as KiCad + and AutoCAD keep theirs: a copy of the file as it was goes in a folder + beside it, "-backups", named by the time that version was saved + ("industrial 2026-10-16 08-30-12.qetz"), and only the newest are kept. + The file is copied, never moved: a save that fails leaves it in place. +*/ +namespace PreviousVersions +{ + /// How many versions to keep (Settings > General > Projects); 0 keeps none + int count(); + void setCount(int count); + /// The folder the versions of @p file_path go in + QString folder(const QString &file_path); + /// The versions kept of @p file_path, newest first + QStringList kept(const QString &file_path); + /// Copy @p file_path, about to be saved over, among its versions and + /// keep only the @p count newest. Nothing is copied when the file does + /// not exist, when @p count is 0, or when the newest version kept + /// holds the same bytes. @return false, with @p error, when the copy + /// could not be made: the save goes on all the same. + bool keep(const QString &file_path, int count, QString *error = nullptr); +} + +#endif // PREVIOUSVERSIONS_H diff --git a/tests/qttest/CMakeLists.txt b/tests/qttest/CMakeLists.txt index 32f68efc8..417cb55e7 100644 --- a/tests/qttest/CMakeLists.txt +++ b/tests/qttest/CMakeLists.txt @@ -1264,6 +1264,17 @@ if(Qt6_VERSION VERSION_LESS 6.5.0) endforeach() endif() +# The versions of a project kept before it is saved over: the newest +# kept, same bytes not kept twice, a failed copy said +# (sources/utils/previousversions.*). +add_executable( + tst_previousversions + tst_previousversions.cpp + ${QET_DIR}/sources/utils/previousversions.cpp) +add_test(NAME tst_previousversions COMMAND tst_previousversions) +target_include_directories(tst_previousversions PRIVATE ${QET_DIR}/sources) +target_link_libraries(tst_previousversions PRIVATE Qt::Test) + # The zip a .qetz project is stored in: round trip, same bytes twice, # unsafe entry names refused (sources/container/qetzip.*, bundled miniz). add_executable( diff --git a/tests/qttest/tst_previousversions.cpp b/tests/qttest/tst_previousversions.cpp new file mode 100644 index 000000000..55cb6927c --- /dev/null +++ b/tests/qttest/tst_previousversions.cpp @@ -0,0 +1,127 @@ +/* + Copyright 2006-2026 The QElectroTech Team + This file is part of QElectroTech. + + QElectroTech is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + QElectroTech is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with QElectroTech. If not, see . +*/ +#include + +#include +#include +#include + +#include "utils/previousversions.h" + +// The versions of a project kept before it is saved over +// (sources/utils/previousversions.*) +class tst_previousversions : public QObject +{ + Q_OBJECT + + QTemporaryDir m_dir; + + // @p path holding @p text, saved at @p when + static void write(const QString &path, const QByteArray &text, const QDateTime &when) + { + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + file.write(text); + file.flush(); //else closing writes, and sets the time to now + QVERIFY(file.setFileTime(when, QFileDevice::FileModificationTime)); + } + + static QByteArray read(const QString &path) + { + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + } + +private slots: + void initTestCase() { QVERIFY(m_dir.isValid()); } + + // Each save keeps the file as it was, newest first, named by the time + // that version was saved, in "-backups" beside it; only the + // newest are kept + void keepsTheNewest() + { + const QString project = m_dir.filePath(QStringLiteral("plant.qetz")); + const QDateTime start(QDate(2026, 10, 16), QTime(8, 0, 0)); + for (int i = 0 ; i < 7 ; ++i) { + write(project, "version " + QByteArray::number(i), start.addSecs(60 * i)); + QVERIFY(PreviousVersions::keep(project, 3)); + } + const QStringList kept = PreviousVersions::kept(project); + QCOMPARE(kept.size(), 3); + QCOMPARE(read(kept.at(0)), QByteArray("version 6")); + QCOMPARE(read(kept.at(2)), QByteArray("version 4")); + QCOMPARE(QFileInfo(kept.at(0)).fileName(), QStringLiteral("plant 2026-10-16 08-06-00.qetz")); + QCOMPARE(QFileInfo(kept.at(0)).absolutePath(), m_dir.filePath(QStringLiteral("plant-backups"))); + // The project itself is untouched + QCOMPARE(read(project), QByteArray("version 6")); + } + + // Saving the same bytes again keeps no second copy + void sameBytesNotKeptTwice() + { + const QString project = m_dir.filePath(QStringLiteral("same.qet")); + const QDateTime when(QDate(2026, 10, 16), QTime(9, 0, 0)); + write(project, "unchanged", when); + QVERIFY(PreviousVersions::keep(project, 5)); + write(project, "unchanged", when.addSecs(30)); + QVERIFY(PreviousVersions::keep(project, 5)); + QCOMPARE(PreviousVersions::kept(project).size(), 1); + } + + // Two versions saved the same second both kept + void sameSecond() + { + const QString project = m_dir.filePath(QStringLiteral("fast.qetz")); + const QDateTime when(QDate(2026, 10, 16), QTime(10, 0, 0)); + write(project, "first", when); + QVERIFY(PreviousVersions::keep(project, 5)); + write(project, "second", when); + QVERIFY(PreviousVersions::keep(project, 5)); + QCOMPARE(PreviousVersions::kept(project).size(), 2); + } + + // Nothing kept for a new file, or when the count is 0; another + // project's versions in the same folder are not counted + void nothingToKeep() + { + const QString project = m_dir.filePath(QStringLiteral("new.qetz")); + QVERIFY(PreviousVersions::keep(project, 5)); + QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project))); + write(project, "x", QDateTime(QDate(2026, 10, 16), QTime(11, 0, 0))); + QVERIFY(PreviousVersions::keep(project, 0)); + QVERIFY(!QFileInfo::exists(PreviousVersions::folder(project))); + QVERIFY(PreviousVersions::kept(m_dir.filePath(QStringLiteral("other.qetz"))).isEmpty()); + } + + // A copy that cannot be made says so, and leaves the project as it is + void copyFails() + { + const QString project = m_dir.filePath(QStringLiteral("blocked.qetz")); + write(project, "data", QDateTime(QDate(2026, 10, 16), QTime(12, 0, 0))); + // A file where the folder should be + write(PreviousVersions::folder(project), "", QDateTime::currentDateTime()); + QString error; + QVERIFY(!PreviousVersions::keep(project, 5, &error)); + QVERIFY(!error.isEmpty()); + QCOMPARE(read(project), QByteArray("data")); + } +}; + +QTEST_GUILESS_MAIN(tst_previousversions) + +#include "tst_previousversions.moc"