Database merge confirmation dialog (#10173)

* Add Entry::calculateDifference()

This new function contains the logic that was previously in
EntryHistoryModel::calculateHistoryModifications().
It allows the re-use to display the differences in case of a merge.

* Introduce Database Merge Confirmation Dialog

Adds a dialog allowing a user to review the changes of a merge operation.
This dialog displays the changes and allows the user to abort the merge
without modifying the database.

Fixes #1152

* Added dry run option to Merger
* Changed behavior when actual merge differs from dry run to just output a warning to console
* Fixed KeeShare conflicting with merge operations in the middle of a merge

---------

Co-authored-by: Jonathan White <support@dmapps.us>
This commit is contained in:
Tamino Bauknecht 2025-09-14 18:02:22 +02:00 committed by GitHub
parent 9a40182a62
commit c0ea6f65f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 952 additions and 276 deletions

View file

@ -44,6 +44,7 @@
#include "gui/FileDialog.h"
#include "gui/GuiTools.h"
#include "gui/MainWindow.h"
#include "gui/MergeDialog.h"
#include "gui/MessageBox.h"
#include "gui/TotpDialog.h"
#include "gui/TotpExportSettingsDialog.h"
@ -1387,18 +1388,30 @@ void DatabaseWidget::mergeDatabase(bool accepted)
return;
}
Merger merger(srcDb.data(), m_db.data());
QStringList changeList = merger.merge();
#ifdef WITH_XC_KEESHARE
// Disable KeeShare while merging to avoid conflicts with incoming changes
KeeShare::instance()->setSharingEnabled(m_db, false);
#endif
if (!changeList.isEmpty()) {
showMessage(tr("Successfully merged the database files."), MessageWidget::Information);
} else {
showMessage(tr("Database was not modified by merge operation."), MessageWidget::Information);
}
auto* mergeDialog = new MergeDialog(srcDb, m_db, this);
connect(mergeDialog, &MergeDialog::databaseMerged, [this](bool changed) {
if (changed) {
showMessage(tr("Successfully merged the selected database."), MessageWidget::Positive);
emit databaseMerged(m_db);
} else {
showMessage(tr("No changes were made by the merge operation."), MessageWidget::Information);
}
});
connect(mergeDialog, &MergeDialog::finished, [this](int result) {
if (result == QDialog::Rejected) {
showMessage(tr("Merge canceled, no changes were made."), MessageWidget::Information);
}
#ifdef WITH_XC_KEESHARE
KeeShare::instance()->setSharingEnabled(m_db, true);
#endif
});
mergeDialog->open();
}
switchToMainView();
emit databaseMerged(m_db);
}
void DatabaseWidget::syncUnlockedDatabase(bool accepted)
@ -1438,7 +1451,7 @@ bool DatabaseWidget::syncWithDatabase(const QSharedPointer<Database>& otherDb, Q
emit updateSyncProgress(50, tr("Syncing..."));
Merger firstMerge(m_db.data(), otherDb.data());
Merger secondMerge(otherDb.data(), m_db.data());
QStringList changeList = firstMerge.merge() + secondMerge.merge();
auto changeList = firstMerge.merge() + secondMerge.merge();
if (!changeList.isEmpty()) {
// Save synced databases

199
src/gui/MergeDialog.cpp Normal file
View file

@ -0,0 +1,199 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program 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 or (at your option)
* version 3 of the License.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "MergeDialog.h"
#include "ui_MergeDialog.h"
#include "core/Database.h"
#include <QPushButton>
#include <QShortcut>
MergeDialog::MergeDialog(QSharedPointer<Database> source, QSharedPointer<Database> target, QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::MergeDialog())
, m_headerContextMenu(new QMenu())
, m_sourceDatabase(std::move(source))
, m_targetDatabase(std::move(target))
{
setAttribute(Qt::WA_DeleteOnClose);
// block input to other windows since other interactions can lead to unexpected merge results
setWindowModality(Qt::WindowModality::ApplicationModal);
m_ui->setupUi(this);
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Merge"));
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setFocus();
connect(m_ui->buttonBox, &QDialogButtonBox::rejected, this, &MergeDialog::cancelMerge);
connect(m_ui->buttonBox, &QDialogButtonBox::accepted, this, &MergeDialog::performMerge);
setupChangeTable();
updateChangeTable();
}
MergeDialog::MergeDialog(const Merger::ChangeList& changes, QWidget* parent)
: QDialog(parent)
, m_ui(new Ui::MergeDialog())
, m_headerContextMenu(new QMenu())
, m_changes(changes)
{
setAttribute(Qt::WA_DeleteOnClose);
m_ui->setupUi(this);
m_ui->buttonBox->button(QDialogButtonBox::Ok)->setFocus();
m_ui->buttonBox->button(QDialogButtonBox::Abort)->hide();
connect(m_ui->buttonBox, &QDialogButtonBox::accepted, this, &MergeDialog::close);
setupChangeTable();
}
MergeDialog::~MergeDialog() = default;
QVector<MergeDialog::MergeDialogColumns> MergeDialog::columns()
{
return {MergeDialogColumns::Group,
MergeDialogColumns::Title,
MergeDialogColumns::Uuid,
MergeDialogColumns::Type,
MergeDialogColumns::Details};
}
int MergeDialog::columnIndex(MergeDialogColumns column)
{
return columns().indexOf(column);
}
QString MergeDialog::columnName(MergeDialogColumns column)
{
switch (column) {
case MergeDialogColumns::Group:
return tr("Group");
case MergeDialogColumns::Title:
return tr("Title");
case MergeDialogColumns::Uuid:
return tr("UUID");
case MergeDialogColumns::Type:
return tr("Change");
case MergeDialogColumns::Details:
return tr("Details");
}
return {};
}
QString MergeDialog::cellValue(const Merger::Change& change, MergeDialogColumns column)
{
switch (column) {
case MergeDialogColumns::Group:
return change.group();
case MergeDialogColumns::Title:
return change.title();
case MergeDialogColumns::Uuid:
if (!change.uuid().isNull()) {
return change.uuid().toString();
}
break;
case MergeDialogColumns::Type:
return change.typeString();
case MergeDialogColumns::Details:
return change.details();
}
return {};
}
bool MergeDialog::isColumnHiddenByDefault(MergeDialogColumns column)
{
return column == MergeDialogColumns::Uuid;
}
void MergeDialog::setupChangeTable()
{
Q_ASSERT(m_ui);
Q_ASSERT(m_ui->changeTable);
m_ui->changeTable->verticalHeader()->setVisible(false);
m_ui->changeTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeMode::Interactive);
m_ui->changeTable->horizontalHeader()->setContextMenuPolicy(Qt::ActionsContextMenu);
m_ui->changeTable->setShowGrid(false);
m_ui->changeTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_ui->changeTable->setSelectionBehavior(QAbstractItemView::SelectRows);
m_ui->changeTable->setSelectionMode(QAbstractItemView::SingleSelection);
// Create the header context menu
for (auto column : columns()) {
auto* action = new QAction(columnName(column), this);
action->setCheckable(true);
action->setChecked(!isColumnHiddenByDefault(column));
connect(action, &QAction::toggled, [this, column](bool checked) {
m_ui->changeTable->setColumnHidden(columnIndex(column), !checked);
m_ui->changeTable->horizontalHeader()->resizeSections(QHeaderView::ResizeMode::ResizeToContents);
});
m_ui->changeTable->horizontalHeader()->addAction(action);
}
}
void MergeDialog::updateChangeTable()
{
Q_ASSERT(m_ui);
Q_ASSERT(m_ui->changeTable);
Q_ASSERT(m_sourceDatabase.get());
Q_ASSERT(m_targetDatabase.get());
m_changes = Merger(m_sourceDatabase.data(), m_targetDatabase.get()).merge(true);
m_ui->changeTable->clear();
auto allColumns = columns();
m_ui->changeTable->setColumnCount(allColumns.size());
m_ui->changeTable->setRowCount(m_changes.size());
for (auto column : allColumns) {
auto name = columnName(column);
auto index = columnIndex(column);
m_ui->changeTable->setHorizontalHeaderItem(index, new QTableWidgetItem(name));
m_ui->changeTable->setColumnHidden(index, isColumnHiddenByDefault(column));
}
for (int row = 0; row < m_changes.size(); ++row) {
const auto& change = m_changes[row];
for (auto column : allColumns) {
m_ui->changeTable->setItem(row, columnIndex(column), new QTableWidgetItem(cellValue(change, column)));
}
}
m_ui->changeTable->horizontalHeader()->resizeSections(QHeaderView::ResizeMode::ResizeToContents);
}
void MergeDialog::performMerge()
{
auto changes = Merger(m_sourceDatabase.data(), m_targetDatabase.data()).merge();
if (changes != m_changes) {
qWarning("Merge results differed from the expected changes. Expected: %d, Actual: %d",
m_changes.size(),
changes.size());
}
emit databaseMerged(!changes.isEmpty());
done(QDialog::Accepted);
}
void MergeDialog::cancelMerge()
{
done(QDialog::Rejected);
}

83
src/gui/MergeDialog.h Normal file
View file

@ -0,0 +1,83 @@
/*
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* This program 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 or (at your option)
* version 3 of the License.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
#ifndef KEEPASSX_MERGEDIALOG_H
#define KEEPASSX_MERGEDIALOG_H
#include "core/Merger.h"
#include <QDialog>
#include <QMenu>
namespace Ui
{
class MergeDialog;
}
class Database;
class MergeDialog : public QDialog
{
Q_OBJECT
public:
/**
* Merge source into copy of target and display changes.
* On user confirmation, merge source into target.
*/
explicit MergeDialog(QSharedPointer<Database> source, QSharedPointer<Database> target, QWidget* parent = nullptr);
/**
* Display given changes.
*/
explicit MergeDialog(const Merger::ChangeList& changes, QWidget* parent = nullptr);
~MergeDialog() override;
signals:
// Signal will be emitted when a normal merge operation has been performed.
void databaseMerged(bool databaseChanged);
private slots:
void performMerge();
void cancelMerge();
private:
enum class MergeDialogColumns
{
Group,
Title,
Uuid,
Type,
Details
};
static QVector<MergeDialogColumns> columns();
static int columnIndex(MergeDialogColumns column);
static QString columnName(MergeDialogColumns column);
static QString cellValue(const Merger::Change& change, MergeDialogColumns column);
static bool isColumnHiddenByDefault(MergeDialogColumns column);
void setupChangeTable();
void updateChangeTable();
QScopedPointer<Ui::MergeDialog> m_ui;
QScopedPointer<QMenu> m_headerContextMenu;
Merger::ChangeList m_changes;
QSharedPointer<Database> m_sourceDatabase;
QSharedPointer<Database> m_targetDatabase;
};
#endif // KEEPASSX_MERGEDIALOG_H

31
src/gui/MergeDialog.ui Normal file
View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MergeDialog</class>
<widget class="QWidget" name="MergeDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>450</height>
</rect>
</property>
<property name="windowTitle">
<string>Database Merge Confirmation</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTableWidget" name="changeTable"/>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Abort|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View file

@ -193,64 +193,7 @@ void EntryHistoryModel::calculateHistoryModifications()
continue;
}
QStringList modifiedFields;
if (*curr->attributes() != *compare->attributes()) {
bool foundAttribute = false;
if (curr->title() != compare->title()) {
modifiedFields << tr("Title");
foundAttribute = true;
}
if (curr->username() != compare->username()) {
modifiedFields << tr("Username");
foundAttribute = true;
}
if (curr->password() != compare->password()) {
modifiedFields << tr("Password");
foundAttribute = true;
}
if (curr->url() != compare->url()) {
modifiedFields << tr("URL");
foundAttribute = true;
}
if (curr->notes() != compare->notes()) {
modifiedFields << tr("Notes");
foundAttribute = true;
}
if (!foundAttribute) {
modifiedFields << tr("Custom Attributes");
}
}
if (curr->iconNumber() != compare->iconNumber() || curr->iconUuid() != compare->iconUuid()) {
modifiedFields << tr("Icon");
}
if (curr->foregroundColor() != compare->foregroundColor()
|| curr->backgroundColor() != compare->backgroundColor()) {
modifiedFields << tr("Color");
}
if (curr->timeInfo().expires() != compare->timeInfo().expires()
|| curr->timeInfo().expiryTime() != compare->timeInfo().expiryTime()) {
modifiedFields << tr("Expiration");
}
if (curr->totp() != compare->totp()) {
modifiedFields << tr("TOTP");
}
if (*curr->customData() != *compare->customData()) {
modifiedFields << tr("Custom Data");
}
if (*curr->attachments() != *compare->attachments()) {
modifiedFields << tr("Attachments");
}
if (*curr->autoTypeAssociations() != *compare->autoTypeAssociations()
|| curr->autoTypeEnabled() != compare->autoTypeEnabled()
|| curr->defaultAutoTypeSequence() != compare->defaultAutoTypeSequence()) {
modifiedFields << tr("Auto-Type");
}
if (curr->tags() != compare->tags()) {
modifiedFields << tr("Tags");
}
auto modifiedFields = curr->calculateDifference(compare);
m_historyModifications << modifiedFields.join(", ");