mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2025-09-22 13:54:41 -04:00
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:
parent
9a40182a62
commit
c0ea6f65f9
20 changed files with 952 additions and 276 deletions
|
@ -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
199
src/gui/MergeDialog.cpp
Normal 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
83
src/gui/MergeDialog.h
Normal 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
31
src/gui/MergeDialog.ui
Normal 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>
|
|
@ -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(", ");
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue