diff --git a/docs/images/database_settings.png b/docs/images/database_settings.png index 1c26a6ee8c..ebc7135604 100644 Binary files a/docs/images/database_settings.png and b/docs/images/database_settings.png differ diff --git a/docs/topics/DatabaseOperations.adoc b/docs/topics/DatabaseOperations.adoc index f29e4b3d66..35bcdbb161 100644 --- a/docs/topics/DatabaseOperations.adoc +++ b/docs/topics/DatabaseOperations.adoc @@ -333,6 +333,7 @@ image::database_settings.png[] * *Max. history size:* When the history of an entry gets above this size, it is truncated. For example, this happens when entries have large attachments. Set this value small to prevent the database from getting too large (we recommend 6 MiB). * *Use recycle bin:* Select this check-box if you want deleted entries to move to the recycle bin instead of being permanently removed. The recycle bin will be created if it does not already exist after your first deletion. To delete entries permanently, you must empty the recycle bin manually. * *Enable compression:* KeePassXC databases can be compressed before being encrypted. Compression reduces the size of the database and does not have any appreciable affect on speed. It is recommended to always save databases with compression. + * *Autosave delay:* Customize the automatic database save operation by delaying it for a set time since the last change. By default, this option is disabled for fast saving, but can be useful for large databases to avoid delays after each change. 3. Click the Security button in the left-hand menu bar to change your database credentials and change encryption settings. + diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 5f5ac1881b..c941e31ea2 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -2151,6 +2151,26 @@ Entries deleted from the recycle bin are removed from the database. + + Autosave delay since last change + + + + Autosave delay + + + + Autosave delay since last change in minutes + + + + min + + + + Autosave delay since last change checkbox + + DatabaseSettingsWidgetKeeShare diff --git a/src/core/Metadata.cpp b/src/core/Metadata.cpp index 52a615e288..c3d4b8e1ba 100644 --- a/src/core/Metadata.cpp +++ b/src/core/Metadata.cpp @@ -28,10 +28,17 @@ const int Metadata::DefaultHistoryMaxItems = 10; const int Metadata::DefaultHistoryMaxSize = 6 * 1024 * 1024; +const int Metadata::DefaultAutosaveDelayMin = 0; // Fallback icon for return by reference static const Metadata::CustomIconData NULL_ICON{}; +namespace customDataKeys +{ + static const QString savedSearch = QStringLiteral("KPXC_SavedSearch"); + static const QString autosaveDelay = QStringLiteral("KPXC_autosaveDelayMin"); +}; // namespace customDataKeys + Metadata::Metadata(QObject* parent) : ModifiableObject(parent) , m_customData(new CustomData(this)) @@ -265,6 +272,19 @@ int Metadata::historyMaxSize() const return m_data.historyMaxSize; } +int Metadata::autosaveDelayMin() const +{ + QString autosaveDelayMinStr = m_customData->value(customDataKeys::autosaveDelay); + if (autosaveDelayMinStr.isNull()) { + // data is not set yet, use default + return Metadata::DefaultAutosaveDelayMin; + } + bool ok; // check for QString to int op failuer + int autosaveDelayMin = autosaveDelayMinStr.toInt(&ok); + Q_ASSERT(ok); + return autosaveDelayMin; +} + CustomData* Metadata::customData() { return m_customData; @@ -478,6 +498,12 @@ void Metadata::setHistoryMaxSize(int value) set(m_data.historyMaxSize, value); } +void Metadata::setAutosaveDelayMin(int value) +{ + Q_ASSERT(value >= 0 && value <= 420000000); + m_customData->set(customDataKeys::autosaveDelay, QString::number(value)); +} + QDateTime Metadata::settingsChanged() const { return m_settingsChanged; @@ -494,7 +520,7 @@ void Metadata::addSavedSearch(const QString& name, const QString& searchtext) auto searches = savedSearches(); searches.insert(name, searchtext); auto json = QJsonDocument::fromVariant(searches); - m_customData->set("KPXC_SavedSearch", json.toJson()); + m_customData->set(customDataKeys::savedSearch, json.toJson()); } void Metadata::deleteSavedSearch(const QString& name) @@ -502,12 +528,12 @@ void Metadata::deleteSavedSearch(const QString& name) auto searches = savedSearches(); searches.remove(name); auto json = QJsonDocument::fromVariant(searches); - m_customData->set("KPXC_SavedSearch", json.toJson()); + m_customData->set(customDataKeys::savedSearch, json.toJson()); } QVariantMap Metadata::savedSearches() { - auto searches = m_customData->value("KPXC_SavedSearch"); + auto searches = m_customData->value(customDataKeys::savedSearch); auto json = QJsonDocument::fromJson(searches.toUtf8()); return json.toVariant().toMap(); } diff --git a/src/core/Metadata.h b/src/core/Metadata.h index ccefdb1c82..6e80ebc099 100644 --- a/src/core/Metadata.h +++ b/src/core/Metadata.h @@ -109,11 +109,13 @@ class Metadata : public ModifiableObject int databaseKeyChangeForce() const; int historyMaxItems() const; int historyMaxSize() const; + int autosaveDelayMin() const; CustomData* customData(); const CustomData* customData() const; static const int DefaultHistoryMaxItems; static const int DefaultHistoryMaxSize; + static const int DefaultAutosaveDelayMin; void setGenerator(const QString& value); void setName(const QString& value); @@ -150,6 +152,7 @@ class Metadata : public ModifiableObject void setMasterKeyChangeForce(int value); void setHistoryMaxItems(int value); void setHistoryMaxSize(int value); + void setAutosaveDelayMin(int value); void setUpdateDatetime(bool value); void addSavedSearch(const QString& name, const QString& searchtext); void deleteSavedSearch(const QString& name); diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index d51e9483d9..203289824a 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -215,6 +215,10 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) m_blockAutoSave = false; + m_autosaveTimer = new QTimer(this); + m_autosaveTimer->setSingleShot(true); + connect(m_autosaveTimer, SIGNAL(timeout()), this, SLOT(onAutosaveDelayTimeout())); + m_searchLimitGroup = config()->get(Config::SearchLimitGroup).toBool(); #ifdef WITH_XC_KEESHARE @@ -1561,13 +1565,42 @@ void DatabaseWidget::onGroupChanged() void DatabaseWidget::onDatabaseModified() { - if (!m_blockAutoSave && config()->get(Config::AutoSaveAfterEveryChange).toBool()) { + refreshSearch(); + int autosaveDelayMs = m_db->metadata()->autosaveDelayMin() * 60 * 1000; // min to msec for QTimer + bool autosaveAfterEveryChangeConfig = config()->get(Config::AutoSaveAfterEveryChange).toBool(); + if (autosaveDelayMs > 0 && autosaveAfterEveryChangeConfig) { + // reset delay when modified + m_autosaveTimer->start(autosaveDelayMs); + return; + } + if (!m_blockAutoSave && autosaveAfterEveryChangeConfig) { save(); } else { // Only block once, then reset m_blockAutoSave = false; } - refreshSearch(); +} + +void DatabaseWidget::onAutosaveDelayTimeout() +{ + const bool isAutosaveDelayEnabled = m_db->metadata()->autosaveDelayMin() > 0; + const bool autosaveAfterEveryChangeConfig = config()->get(Config::AutoSaveAfterEveryChange).toBool(); + if (!(isAutosaveDelayEnabled && autosaveAfterEveryChangeConfig)) { + // User might disable the delay/autosave while the timer is running + return; + } + if (!m_blockAutoSave) { + save(); + } else { + // Only block once, then reset + m_blockAutoSave = false; + } +} + +void DatabaseWidget::triggerAutosaveTimer() +{ + m_autosaveTimer->stop(); + QMetaObject::invokeMethod(m_autosaveTimer, "timeout"); } void DatabaseWidget::onDatabaseNonDataChanged() @@ -2037,6 +2070,7 @@ bool DatabaseWidget::save() if (performSave(errorMessage)) { m_saveAttempts = 0; m_blockAutoSave = false; + m_autosaveTimer->stop(); // stop autosave delay to avoid triggering another save return true; } diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index 571db44a46..6812600092 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -233,6 +233,7 @@ public slots: int autoHideTimeout = MessageWidget::DefaultAutoHideTimeout); void showErrorMessage(const QString& errorMessage); void hideMessage(); + void triggerAutosaveTimer(); protected: void closeEvent(QCloseEvent* event) override; @@ -252,6 +253,7 @@ private slots: void onGroupChanged(); void onDatabaseModified(); void onDatabaseNonDataChanged(); + void onAutosaveDelayTimeout(); void connectDatabaseSignals(); void loadDatabase(bool accepted); void unlockDatabase(bool accepted); @@ -307,6 +309,9 @@ private slots: // Autoreload bool m_blockAutoSave; + // Autosave delay + QPointer m_autosaveTimer; + // Auto-Type related QString m_searchStringForAutoType; }; diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.cpp b/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.cpp index 43b0a72af0..d43e6d8c53 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.cpp +++ b/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.cpp @@ -31,6 +31,7 @@ DatabaseSettingsWidgetGeneral::DatabaseSettingsWidgetGeneral(QWidget* parent) connect(m_ui->historyMaxItemsCheckBox, SIGNAL(toggled(bool)), m_ui->historyMaxItemsSpinBox, SLOT(setEnabled(bool))); connect(m_ui->historyMaxSizeCheckBox, SIGNAL(toggled(bool)), m_ui->historyMaxSizeSpinBox, SLOT(setEnabled(bool))); + connect(m_ui->autosaveDelayCheckBox, SIGNAL(toggled(bool)), m_ui->autosaveDelaySpinBox, SLOT(setEnabled(bool))); } DatabaseSettingsWidgetGeneral::~DatabaseSettingsWidgetGeneral() @@ -64,6 +65,13 @@ void DatabaseSettingsWidgetGeneral::initialize() m_ui->historyMaxSizeSpinBox->setEnabled(false); m_ui->historyMaxSizeCheckBox->setChecked(false); } + if (meta->autosaveDelayMin() > 0) { + m_ui->autosaveDelaySpinBox->setValue(meta->autosaveDelayMin()); + m_ui->autosaveDelayCheckBox->setChecked(true); + } else { + m_ui->autosaveDelayCheckBox->setChecked(false); + m_ui->autosaveDelaySpinBox->setEnabled(false); + } } void DatabaseSettingsWidgetGeneral::uninitialize() @@ -134,6 +142,12 @@ bool DatabaseSettingsWidgetGeneral::save() truncate = true; } + int autosaveDelayMin = 0; + if (m_ui->autosaveDelayCheckBox->isChecked()) { + autosaveDelayMin = m_ui->autosaveDelaySpinBox->value(); + } + meta->setAutosaveDelayMin(autosaveDelayMin); + if (truncate) { const QList allEntries = m_db->rootGroup()->entriesRecursive(false); for (Entry* entry : allEntries) { diff --git a/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui b/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui index ab6d88c720..939b699631 100644 --- a/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui +++ b/src/gui/dbsettings/DatabaseSettingsWidgetGeneral.ui @@ -7,7 +7,7 @@ 0 0 453 - 374 + 394 @@ -205,6 +205,58 @@ removed from the database. + + + + + + Autosave delay since last change + + + Autosave delay since last change checkbox + + + Autosave delay + + + + + + + Autosave delay since last change in minutes + + + Autosave delay since last change in minutes + + + min + + + 0 + + + 420000000 + + + 5 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index acd8a84a3c..a74d1e29b9 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -1469,6 +1469,10 @@ void TestGui::testDatabaseSettings() auto* dbSettingsDialog = m_dbWidget->findChild("databaseSettingsDialog"); auto* transformRoundsSpinBox = dbSettingsDialog->findChild("transformRoundsSpinBox"); auto advancedToggle = dbSettingsDialog->findChild("advancedSettingsToggle"); + auto* autosaveDelayCheckBox = dbSettingsDialog->findChild("autosaveDelayCheckBox"); + auto* autosaveDelaySpinBox = dbSettingsDialog->findChild("autosaveDelaySpinBox"); + auto* dbSettingsButtonBox = dbSettingsDialog->findChild("buttonBox"); + int autosaveDelayTestValue = 2; advancedToggle->setChecked(true); QApplication::processEvents(); @@ -1478,7 +1482,136 @@ void TestGui::testDatabaseSettings() QTest::keyClick(transformRoundsSpinBox, Qt::Key_Enter); QTRY_COMPARE(m_db->kdf()->rounds(), 123456); + // test disable and default values for maximum history items and size + triggerAction("actionDatabaseSettings"); + auto* historyMaxItemsCheckBox = dbSettingsDialog->findChild("historyMaxItemsCheckBox"); + auto* historyMaxItemsSpinBox = dbSettingsDialog->findChild("historyMaxItemsSpinBox"); + auto* historyMaxSizeCheckBox = dbSettingsDialog->findChild("historyMaxSizeCheckBox"); + auto* historyMaxSizeSpinBox = dbSettingsDialog->findChild("historyMaxSizeSpinBox"); + // test defaults + QCOMPARE(historyMaxItemsSpinBox->value(), Metadata::DefaultHistoryMaxItems); + QCOMPARE(historyMaxSizeSpinBox->value(), qRound(Metadata::DefaultHistoryMaxSize / qreal(1024 * 1024))); + // disable and test setting as well + historyMaxItemsCheckBox->setChecked(false); + historyMaxSizeCheckBox->setChecked(false); + QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton); + QTRY_COMPARE(m_db->metadata()->historyMaxItems(), -1); + QTRY_COMPARE(m_db->metadata()->historyMaxSize(), -1); + // then open to check the saved disabled state in gui + triggerAction("actionDatabaseSettings"); + QCOMPARE(historyMaxItemsCheckBox->isChecked(), false); + QCOMPARE(historyMaxSizeCheckBox->isChecked(), false); + QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Cancel), Qt::LeftButton); + + // Test loading default values and setting autosaveDelay + triggerAction("actionDatabaseSettings"); + QVERIFY(autosaveDelayCheckBox->isChecked() == false); + autosaveDelayCheckBox->toggle(); + autosaveDelaySpinBox->setValue(autosaveDelayTestValue); + QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton); + QTRY_COMPARE(m_db->metadata()->autosaveDelayMin(), autosaveDelayTestValue); + checkSaveDatabase(); + + // Test loading autosaveDelay non-default values + triggerAction("actionDatabaseSettings"); + QTRY_COMPARE(autosaveDelayCheckBox->isChecked(), true); + QTRY_COMPARE(autosaveDelaySpinBox->value(), autosaveDelayTestValue); + QTest::mouseClick(dbSettingsButtonBox->button(QDialogButtonBox::Cancel), Qt::LeftButton); + + // test autosave delay + + // 1 init + config()->set(Config::AutoSaveAfterEveryChange, true); + QSignalSpy writeDbSignalSpy(m_db.data(), &Database::databaseSaved); + + // 2 create new entries + + // 2.a) Click the new entry button and set the title + auto* entryNewAction = m_mainWindow->findChild("actionEntryNew"); + QVERIFY(entryNewAction->isEnabled()); + + auto* toolBar = m_mainWindow->findChild("toolBar"); + QVERIFY(toolBar); + + QWidget* entryNewWidget = toolBar->widgetForAction(entryNewAction); + + QTest::mouseClick(entryNewWidget, Qt::LeftButton); + QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode); + + auto* editEntryWidget = m_dbWidget->findChild("editEntryWidget"); + QVERIFY(editEntryWidget); + auto* titleEdit = editEntryWidget->findChild("titleEdit"); + QVERIFY(titleEdit); + + QTest::keyClicks(titleEdit, "Test autosaveDelay 1"); + + // 2.b) Save changes + editEntryWidget->setCurrentPage(0); + auto* editEntryWidgetButtonBox = editEntryWidget->findChild("buttonBox"); + QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton); + + // 2.c) Make sure file was not modified yet + Tools::wait(150); // due to modify timer + QTRY_COMPARE(writeDbSignalSpy.count(), 0); + + // 2.d) Create second entry to test delay timer reset + QTest::mouseClick(entryNewWidget, Qt::LeftButton); + QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode); + QTest::keyClicks(titleEdit, "Test autosaveDelay 2"); + + // 2.e) Save changes + editEntryWidget->setCurrentPage(0); + editEntryWidgetButtonBox = editEntryWidget->findChild("buttonBox"); + QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton); + + // 3 Double check both true negative and true positive + // 3.a) Test unmodified prior to delay timeout + Tools::wait(150); // due to modify timer + QTRY_COMPARE(writeDbSignalSpy.count(), 0); + + // 3.b) Test modification time after expected + m_dbWidget->triggerAutosaveTimer(); + QTRY_COMPARE(writeDbSignalSpy.count(), 1); + + // 4 Test no delay when disabled autosave or autosaveDelay + // 4.a) create new entry + QTest::mouseClick(entryNewWidget, Qt::LeftButton); + QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode); + QTest::keyClicks(titleEdit, "Test autosaveDelay 3"); + + // 4.b) Save changes + editEntryWidget->setCurrentPage(0); + editEntryWidgetButtonBox = editEntryWidget->findChild("buttonBox"); + QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton); + + // 4.c) Start timer + Tools::wait(150); // due to modify timer + + // 4.d) Disable autosave + config()->set(Config::AutoSaveAfterEveryChange, false); + + // 4.e) Make sure changes are not saved + m_dbWidget->triggerAutosaveTimer(); + QTRY_COMPARE(writeDbSignalSpy.count(), 1); + + // 4.f) Repeat for autosaveDelay + config()->set(Config::AutoSaveAfterEveryChange, true); + QTest::mouseClick(entryNewWidget, Qt::LeftButton); + QCOMPARE(m_dbWidget->currentMode(), DatabaseWidget::Mode::EditMode); + QTest::keyClicks(titleEdit, "Test autosaveDelay 4"); + editEntryWidget->setCurrentPage(0); + editEntryWidgetButtonBox = editEntryWidget->findChild("buttonBox"); + QTest::mouseClick(editEntryWidgetButtonBox->button(QDialogButtonBox::Ok), Qt::LeftButton); + Tools::wait(150); // due to modify timer + m_db->metadata()->setAutosaveDelayMin(0); + + // 4.g) Make sure changes are not saved + m_dbWidget->triggerAutosaveTimer(); + QTRY_COMPARE(writeDbSignalSpy.count(), 1); + + // 5 Cleanup + config()->set(Config::AutoSaveAfterEveryChange, false); } void TestGui::testDatabaseLocking()