mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2024-10-01 01:26:01 -04:00
Add Merge database utility function (#47)
Thank you to @TheZ3ro and @monomon for there major contributions to this PR!
This commit is contained in:
parent
e00c6f9c77
commit
e25cd9ba48
@ -282,6 +282,12 @@ void Database::recycleGroup(Group* group)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Database::merge(const Database* other)
|
||||||
|
{
|
||||||
|
m_rootGroup->merge(other->rootGroup());
|
||||||
|
Q_EMIT modified();
|
||||||
|
}
|
||||||
|
|
||||||
void Database::setEmitModified(bool value)
|
void Database::setEmitModified(bool value)
|
||||||
{
|
{
|
||||||
if (m_emitModified && !value) {
|
if (m_emitModified && !value) {
|
||||||
|
@ -105,6 +105,7 @@ public:
|
|||||||
void recycleGroup(Group* group);
|
void recycleGroup(Group* group);
|
||||||
void setEmitModified(bool value);
|
void setEmitModified(bool value);
|
||||||
void copyAttributesFrom(const Database* other);
|
void copyAttributesFrom(const Database* other);
|
||||||
|
void merge(const Database* other);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a unique id that is only valid as long as the Database exists.
|
* Returns a unique id that is only valid as long as the Database exists.
|
||||||
|
@ -32,6 +32,7 @@ Group::Group()
|
|||||||
m_data.isExpanded = true;
|
m_data.isExpanded = true;
|
||||||
m_data.autoTypeEnabled = Inherit;
|
m_data.autoTypeEnabled = Inherit;
|
||||||
m_data.searchingEnabled = Inherit;
|
m_data.searchingEnabled = Inherit;
|
||||||
|
m_data.mergeMode = ModeInherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
Group::~Group()
|
Group::~Group()
|
||||||
@ -196,6 +197,19 @@ Group::TriState Group::searchingEnabled() const
|
|||||||
return m_data.searchingEnabled;
|
return m_data.searchingEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Group::MergeMode Group::mergeMode() const
|
||||||
|
{
|
||||||
|
if (m_data.mergeMode == Group::MergeMode::ModeInherit) {
|
||||||
|
if (m_parent) {
|
||||||
|
return m_parent->mergeMode();
|
||||||
|
} else {
|
||||||
|
return Group::MergeMode::KeepNewer; // fallback
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return m_data.mergeMode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Entry* Group::lastTopVisibleEntry() const
|
Entry* Group::lastTopVisibleEntry() const
|
||||||
{
|
{
|
||||||
return m_lastTopVisibleEntry;
|
return m_lastTopVisibleEntry;
|
||||||
@ -303,6 +317,11 @@ void Group::setExpiryTime(const QDateTime& dateTime)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Group::setMergeMode(MergeMode newMode)
|
||||||
|
{
|
||||||
|
set(m_data.mergeMode, newMode);
|
||||||
|
}
|
||||||
|
|
||||||
Group* Group::parentGroup()
|
Group* Group::parentGroup()
|
||||||
{
|
{
|
||||||
return m_parent;
|
return m_parent;
|
||||||
@ -440,6 +459,18 @@ QList<Entry*> Group::entriesRecursive(bool includeHistoryItems) const
|
|||||||
return entryList;
|
return entryList;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Entry* Group::findEntry(const Uuid& uuid)
|
||||||
|
{
|
||||||
|
Q_ASSERT(!uuid.isNull());
|
||||||
|
for (Entry* entry : asConst(m_entries)) {
|
||||||
|
if (entry->uuid() == uuid) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
QList<const Group*> Group::groupsRecursive(bool includeSelf) const
|
QList<const Group*> Group::groupsRecursive(bool includeSelf) const
|
||||||
{
|
{
|
||||||
QList<const Group*> groupList;
|
QList<const Group*> groupList;
|
||||||
@ -490,6 +521,44 @@ QSet<Uuid> Group::customIconsRecursive() const
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Group::merge(const Group* other)
|
||||||
|
{
|
||||||
|
// merge entries
|
||||||
|
const QList<Entry*> dbEntries = other->entries();
|
||||||
|
for (Entry* entry : dbEntries) {
|
||||||
|
// entries are searched by uuid
|
||||||
|
if (!findEntry(entry->uuid())) {
|
||||||
|
entry->clone(Entry::CloneNoFlags)->setGroup(this);
|
||||||
|
} else {
|
||||||
|
resolveConflict(this->findEntry(entry->uuid()), entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// merge groups (recursively)
|
||||||
|
const QList<Group*> dbChildren = other->children();
|
||||||
|
for (Group* group : dbChildren) {
|
||||||
|
// groups are searched by name instead of uuid
|
||||||
|
if (this->findChildByName(group->name())) {
|
||||||
|
this->findChildByName(group->name())->merge(group);
|
||||||
|
} else {
|
||||||
|
group->setParent(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Q_EMIT modified();
|
||||||
|
}
|
||||||
|
|
||||||
|
Group* Group::findChildByName(const QString& name)
|
||||||
|
{
|
||||||
|
for (Group* group : asConst(m_children)) {
|
||||||
|
if (group->name() == name) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
Group* Group::clone(Entry::CloneFlags entryFlags) const
|
Group* Group::clone(Entry::CloneFlags entryFlags) const
|
||||||
{
|
{
|
||||||
Group* clonedGroup = new Group();
|
Group* clonedGroup = new Group();
|
||||||
@ -624,6 +693,14 @@ void Group::recCreateDelObjects()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Group::markOlderEntry(Entry* entry)
|
||||||
|
{
|
||||||
|
entry->attributes()->set(
|
||||||
|
"merged",
|
||||||
|
QString("older entry merged from database \"%1\"").arg(entry->group()->database()->metadata()->name())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool Group::resolveSearchingEnabled() const
|
bool Group::resolveSearchingEnabled() const
|
||||||
{
|
{
|
||||||
switch (m_data.searchingEnabled) {
|
switch (m_data.searchingEnabled) {
|
||||||
@ -663,3 +740,39 @@ bool Group::resolveAutoTypeEnabled() const
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Group::resolveConflict(Entry* existingEntry, Entry* otherEntry)
|
||||||
|
{
|
||||||
|
const QDateTime timeExisting = existingEntry->timeInfo().lastModificationTime();
|
||||||
|
const QDateTime timeOther = otherEntry->timeInfo().lastModificationTime();
|
||||||
|
|
||||||
|
Entry* clonedEntry;
|
||||||
|
|
||||||
|
switch(this->mergeMode()) {
|
||||||
|
case KeepBoth:
|
||||||
|
// if one entry is newer, create a clone and add it to the group
|
||||||
|
if (timeExisting > timeOther) {
|
||||||
|
clonedEntry = otherEntry->clone(Entry::CloneNoFlags);
|
||||||
|
clonedEntry->setGroup(this);
|
||||||
|
this->markOlderEntry(clonedEntry);
|
||||||
|
} else if (timeExisting < timeOther) {
|
||||||
|
clonedEntry = otherEntry->clone(Entry::CloneNoFlags);
|
||||||
|
clonedEntry->setGroup(this);
|
||||||
|
this->markOlderEntry(existingEntry);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case KeepNewer:
|
||||||
|
if (timeExisting < timeOther) {
|
||||||
|
// only if other entry is newer, replace existing one
|
||||||
|
this->removeEntry(existingEntry);
|
||||||
|
this->addEntry(otherEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case KeepExisting:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// do nothing
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -34,6 +34,7 @@ class Group : public QObject
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
enum TriState { Inherit, Enable, Disable };
|
enum TriState { Inherit, Enable, Disable };
|
||||||
|
enum MergeMode { ModeInherit, KeepBoth, KeepNewer, KeepExisting };
|
||||||
|
|
||||||
struct GroupData
|
struct GroupData
|
||||||
{
|
{
|
||||||
@ -46,6 +47,7 @@ public:
|
|||||||
QString defaultAutoTypeSequence;
|
QString defaultAutoTypeSequence;
|
||||||
Group::TriState autoTypeEnabled;
|
Group::TriState autoTypeEnabled;
|
||||||
Group::TriState searchingEnabled;
|
Group::TriState searchingEnabled;
|
||||||
|
Group::MergeMode mergeMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
Group();
|
Group();
|
||||||
@ -66,6 +68,7 @@ public:
|
|||||||
QString defaultAutoTypeSequence() const;
|
QString defaultAutoTypeSequence() const;
|
||||||
Group::TriState autoTypeEnabled() const;
|
Group::TriState autoTypeEnabled() const;
|
||||||
Group::TriState searchingEnabled() const;
|
Group::TriState searchingEnabled() const;
|
||||||
|
Group::MergeMode mergeMode() const;
|
||||||
bool resolveSearchingEnabled() const;
|
bool resolveSearchingEnabled() const;
|
||||||
bool resolveAutoTypeEnabled() const;
|
bool resolveAutoTypeEnabled() const;
|
||||||
Entry* lastTopVisibleEntry() const;
|
Entry* lastTopVisibleEntry() const;
|
||||||
@ -74,6 +77,8 @@ public:
|
|||||||
static const int DefaultIconNumber;
|
static const int DefaultIconNumber;
|
||||||
static const int RecycleBinIconNumber;
|
static const int RecycleBinIconNumber;
|
||||||
|
|
||||||
|
Entry* findEntry(const Uuid& uuid);
|
||||||
|
Group* findChildByName(const QString& name);
|
||||||
void setUuid(const Uuid& uuid);
|
void setUuid(const Uuid& uuid);
|
||||||
void setName(const QString& name);
|
void setName(const QString& name);
|
||||||
void setNotes(const QString& notes);
|
void setNotes(const QString& notes);
|
||||||
@ -87,6 +92,7 @@ public:
|
|||||||
void setLastTopVisibleEntry(Entry* entry);
|
void setLastTopVisibleEntry(Entry* entry);
|
||||||
void setExpires(bool value);
|
void setExpires(bool value);
|
||||||
void setExpiryTime(const QDateTime& dateTime);
|
void setExpiryTime(const QDateTime& dateTime);
|
||||||
|
void setMergeMode(MergeMode newMode);
|
||||||
|
|
||||||
void setUpdateTimeinfo(bool value);
|
void setUpdateTimeinfo(bool value);
|
||||||
|
|
||||||
@ -113,6 +119,7 @@ public:
|
|||||||
*/
|
*/
|
||||||
Group* clone(Entry::CloneFlags entryFlags = Entry::CloneNewUuid | Entry::CloneResetTimeInfo) const;
|
Group* clone(Entry::CloneFlags entryFlags = Entry::CloneNewUuid | Entry::CloneResetTimeInfo) const;
|
||||||
void copyDataFrom(const Group* other);
|
void copyDataFrom(const Group* other);
|
||||||
|
void merge(const Group* other);
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void dataChanged(Group* group);
|
void dataChanged(Group* group);
|
||||||
@ -142,6 +149,8 @@ private:
|
|||||||
void addEntry(Entry* entry);
|
void addEntry(Entry* entry);
|
||||||
void removeEntry(Entry* entry);
|
void removeEntry(Entry* entry);
|
||||||
void setParent(Database* db);
|
void setParent(Database* db);
|
||||||
|
void markOlderEntry(Entry* entry);
|
||||||
|
void resolveConflict(Entry* existingEntry, Entry* otherEntry);
|
||||||
|
|
||||||
void recSetDatabase(Database* db);
|
void recSetDatabase(Database* db);
|
||||||
void cleanupParent();
|
void cleanupParent();
|
||||||
|
@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
#include "autotype/AutoType.h"
|
#include "autotype/AutoType.h"
|
||||||
#include "core/Config.h"
|
#include "core/Config.h"
|
||||||
|
#include "core/Global.h"
|
||||||
#include "core/Database.h"
|
#include "core/Database.h"
|
||||||
#include "core/Group.h"
|
#include "core/Group.h"
|
||||||
#include "core/Metadata.h"
|
#include "core/Metadata.h"
|
||||||
@ -192,6 +193,21 @@ void DatabaseTabWidget::openDatabase(const QString& fileName, const QString& pw,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DatabaseTabWidget::mergeDatabase()
|
||||||
|
{
|
||||||
|
QString filter = QString("%1 (*.kdbx);;%2 (*)").arg(tr("KeePass 2 Database"), tr("All files"));
|
||||||
|
const QString fileName = fileDialog()->getOpenFileName(this, tr("Merge database"), QString(),
|
||||||
|
filter);
|
||||||
|
if (!fileName.isEmpty()) {
|
||||||
|
mergeDatabase(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DatabaseTabWidget::mergeDatabase(const QString& fileName)
|
||||||
|
{
|
||||||
|
currentDatabaseWidget()->switchToOpenMergeDatabase(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
void DatabaseTabWidget::importKeePass1Database()
|
void DatabaseTabWidget::importKeePass1Database()
|
||||||
{
|
{
|
||||||
QString fileName = fileDialog()->getOpenFileName(this, tr("Open KeePass 1 database"), QString(),
|
QString fileName = fileDialog()->getOpenFileName(this, tr("Open KeePass 1 database"), QString(),
|
||||||
|
@ -55,6 +55,7 @@ public:
|
|||||||
~DatabaseTabWidget();
|
~DatabaseTabWidget();
|
||||||
void openDatabase(const QString& fileName, const QString& pw = QString(),
|
void openDatabase(const QString& fileName, const QString& pw = QString(),
|
||||||
const QString& keyFile = QString());
|
const QString& keyFile = QString());
|
||||||
|
void mergeDatabase(const QString& fileName);
|
||||||
DatabaseWidget* currentDatabaseWidget();
|
DatabaseWidget* currentDatabaseWidget();
|
||||||
bool hasLockableDatabases() const;
|
bool hasLockableDatabases() const;
|
||||||
|
|
||||||
@ -63,6 +64,7 @@ public:
|
|||||||
public Q_SLOTS:
|
public Q_SLOTS:
|
||||||
void newDatabase();
|
void newDatabase();
|
||||||
void openDatabase();
|
void openDatabase();
|
||||||
|
void mergeDatabase();
|
||||||
void importKeePass1Database();
|
void importKeePass1Database();
|
||||||
bool saveDatabase(int index = -1);
|
bool saveDatabase(int index = -1);
|
||||||
bool saveDatabaseAs(int index = -1);
|
bool saveDatabaseAs(int index = -1);
|
||||||
|
@ -118,6 +118,8 @@ DatabaseWidget::DatabaseWidget(Database* db, QWidget* parent)
|
|||||||
m_databaseSettingsWidget->setObjectName("databaseSettingsWidget");
|
m_databaseSettingsWidget->setObjectName("databaseSettingsWidget");
|
||||||
m_databaseOpenWidget = new DatabaseOpenWidget();
|
m_databaseOpenWidget = new DatabaseOpenWidget();
|
||||||
m_databaseOpenWidget->setObjectName("databaseOpenWidget");
|
m_databaseOpenWidget->setObjectName("databaseOpenWidget");
|
||||||
|
m_databaseOpenMergeWidget = new DatabaseOpenWidget();
|
||||||
|
m_databaseOpenMergeWidget->setObjectName("databaseOpenMergeWidget");
|
||||||
m_keepass1OpenWidget = new KeePass1OpenWidget();
|
m_keepass1OpenWidget = new KeePass1OpenWidget();
|
||||||
m_keepass1OpenWidget->setObjectName("keepass1OpenWidget");
|
m_keepass1OpenWidget->setObjectName("keepass1OpenWidget");
|
||||||
m_unlockDatabaseWidget = new UnlockDatabaseWidget();
|
m_unlockDatabaseWidget = new UnlockDatabaseWidget();
|
||||||
@ -129,6 +131,7 @@ DatabaseWidget::DatabaseWidget(Database* db, QWidget* parent)
|
|||||||
addWidget(m_databaseSettingsWidget);
|
addWidget(m_databaseSettingsWidget);
|
||||||
addWidget(m_historyEditEntryWidget);
|
addWidget(m_historyEditEntryWidget);
|
||||||
addWidget(m_databaseOpenWidget);
|
addWidget(m_databaseOpenWidget);
|
||||||
|
addWidget(m_databaseOpenMergeWidget);
|
||||||
addWidget(m_keepass1OpenWidget);
|
addWidget(m_keepass1OpenWidget);
|
||||||
addWidget(m_unlockDatabaseWidget);
|
addWidget(m_unlockDatabaseWidget);
|
||||||
|
|
||||||
@ -147,6 +150,7 @@ DatabaseWidget::DatabaseWidget(Database* db, QWidget* parent)
|
|||||||
connect(m_changeMasterKeyWidget, SIGNAL(editFinished(bool)), SLOT(updateMasterKey(bool)));
|
connect(m_changeMasterKeyWidget, SIGNAL(editFinished(bool)), SLOT(updateMasterKey(bool)));
|
||||||
connect(m_databaseSettingsWidget, SIGNAL(editFinished(bool)), SLOT(switchToView(bool)));
|
connect(m_databaseSettingsWidget, SIGNAL(editFinished(bool)), SLOT(switchToView(bool)));
|
||||||
connect(m_databaseOpenWidget, SIGNAL(editFinished(bool)), SLOT(openDatabase(bool)));
|
connect(m_databaseOpenWidget, SIGNAL(editFinished(bool)), SLOT(openDatabase(bool)));
|
||||||
|
connect(m_databaseOpenMergeWidget, SIGNAL(editFinished(bool)), SLOT(mergeDatabase(bool)));
|
||||||
connect(m_keepass1OpenWidget, SIGNAL(editFinished(bool)), SLOT(openDatabase(bool)));
|
connect(m_keepass1OpenWidget, SIGNAL(editFinished(bool)), SLOT(openDatabase(bool)));
|
||||||
connect(m_unlockDatabaseWidget, SIGNAL(editFinished(bool)), SLOT(unlockDatabase(bool)));
|
connect(m_unlockDatabaseWidget, SIGNAL(editFinished(bool)), SLOT(unlockDatabase(bool)));
|
||||||
connect(this, SIGNAL(currentChanged(int)), this, SLOT(emitCurrentModeChanged()));
|
connect(this, SIGNAL(currentChanged(int)), this, SLOT(emitCurrentModeChanged()));
|
||||||
@ -663,6 +667,28 @@ void DatabaseWidget::openDatabase(bool accepted)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DatabaseWidget::mergeDatabase(bool accepted)
|
||||||
|
{
|
||||||
|
if (accepted) {
|
||||||
|
if (!m_db) {
|
||||||
|
MessageBox::critical(this, tr("Error"), tr("No current database."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Database* srcDb = static_cast<DatabaseOpenWidget*>(sender())->database();
|
||||||
|
|
||||||
|
if (!srcDb) {
|
||||||
|
MessageBox::critical(this, tr("Error"), tr("No source database, nothing to do."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_db->merge(srcDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentWidget(m_mainWidget);
|
||||||
|
Q_EMIT databaseMerged(m_db);
|
||||||
|
}
|
||||||
|
|
||||||
void DatabaseWidget::unlockDatabase(bool accepted)
|
void DatabaseWidget::unlockDatabase(bool accepted)
|
||||||
{
|
{
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
@ -745,6 +771,19 @@ void DatabaseWidget::switchToOpenDatabase(const QString& fileName, const QString
|
|||||||
m_databaseOpenWidget->enterKey(password, keyFile);
|
m_databaseOpenWidget->enterKey(password, keyFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DatabaseWidget::switchToOpenMergeDatabase(const QString& fileName)
|
||||||
|
{
|
||||||
|
m_databaseOpenMergeWidget->load(fileName);
|
||||||
|
setCurrentWidget(m_databaseOpenMergeWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DatabaseWidget::switchToOpenMergeDatabase(const QString& fileName, const QString& password,
|
||||||
|
const QString& keyFile)
|
||||||
|
{
|
||||||
|
switchToOpenMergeDatabase(fileName);
|
||||||
|
m_databaseOpenMergeWidget->enterKey(password, keyFile);
|
||||||
|
}
|
||||||
|
|
||||||
void DatabaseWidget::switchToImportKeepass1(const QString& fileName)
|
void DatabaseWidget::switchToImportKeepass1(const QString& fileName)
|
||||||
{
|
{
|
||||||
updateFilename(fileName);
|
updateFilename(fileName);
|
||||||
@ -856,6 +895,12 @@ bool DatabaseWidget::isInSearchMode() const
|
|||||||
return m_entryView->inEntryListMode();
|
return m_entryView->inEntryListMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Group* DatabaseWidget::currentGroup() const
|
||||||
|
{
|
||||||
|
return isInSearchMode() ? m_lastGroup
|
||||||
|
: m_groupView->currentGroup();
|
||||||
|
}
|
||||||
|
|
||||||
void DatabaseWidget::clearLastGroup(Group* group)
|
void DatabaseWidget::clearLastGroup(Group* group)
|
||||||
{
|
{
|
||||||
if (group) {
|
if (group) {
|
||||||
@ -956,3 +1001,11 @@ bool DatabaseWidget::currentEntryHasNotes()
|
|||||||
}
|
}
|
||||||
return !currentEntry->notes().isEmpty();
|
return !currentEntry->notes().isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GroupView* DatabaseWidget::groupView() {
|
||||||
|
return m_groupView;
|
||||||
|
}
|
||||||
|
|
||||||
|
EntryView* DatabaseWidget::entryView() {
|
||||||
|
return m_entryView;
|
||||||
|
}
|
||||||
|
@ -62,6 +62,7 @@ public:
|
|||||||
bool canDeleteCurrentGroup() const;
|
bool canDeleteCurrentGroup() const;
|
||||||
bool isInSearchMode() const;
|
bool isInSearchMode() const;
|
||||||
QString getCurrentSearch();
|
QString getCurrentSearch();
|
||||||
|
Group* currentGroup() const;
|
||||||
int addWidget(QWidget* w);
|
int addWidget(QWidget* w);
|
||||||
void setCurrentIndex(int index);
|
void setCurrentIndex(int index);
|
||||||
void setCurrentWidget(QWidget* widget);
|
void setCurrentWidget(QWidget* widget);
|
||||||
@ -83,6 +84,8 @@ public:
|
|||||||
bool currentEntryHasPassword();
|
bool currentEntryHasPassword();
|
||||||
bool currentEntryHasUrl();
|
bool currentEntryHasUrl();
|
||||||
bool currentEntryHasNotes();
|
bool currentEntryHasNotes();
|
||||||
|
GroupView* groupView();
|
||||||
|
EntryView* entryView();
|
||||||
|
|
||||||
Q_SIGNALS:
|
Q_SIGNALS:
|
||||||
void closeRequest();
|
void closeRequest();
|
||||||
@ -90,6 +93,7 @@ Q_SIGNALS:
|
|||||||
void groupChanged();
|
void groupChanged();
|
||||||
void entrySelectionChanged();
|
void entrySelectionChanged();
|
||||||
void databaseChanged(Database* newDb);
|
void databaseChanged(Database* newDb);
|
||||||
|
void databaseMerged(Database* mergedDb);
|
||||||
void groupContextMenuRequested(const QPoint& globalPos);
|
void groupContextMenuRequested(const QPoint& globalPos);
|
||||||
void entryContextMenuRequested(const QPoint& globalPos);
|
void entryContextMenuRequested(const QPoint& globalPos);
|
||||||
void unlockedDatabase();
|
void unlockedDatabase();
|
||||||
@ -116,12 +120,15 @@ public Q_SLOTS:
|
|||||||
void openUrlForEntry(Entry* entry);
|
void openUrlForEntry(Entry* entry);
|
||||||
void createGroup();
|
void createGroup();
|
||||||
void deleteGroup();
|
void deleteGroup();
|
||||||
|
void switchToView(bool accepted);
|
||||||
void switchToEntryEdit();
|
void switchToEntryEdit();
|
||||||
void switchToGroupEdit();
|
void switchToGroupEdit();
|
||||||
void switchToMasterKeyChange();
|
void switchToMasterKeyChange();
|
||||||
void switchToDatabaseSettings();
|
void switchToDatabaseSettings();
|
||||||
void switchToOpenDatabase(const QString& fileName);
|
void switchToOpenDatabase(const QString& fileName);
|
||||||
void switchToOpenDatabase(const QString& fileName, const QString& password, const QString& keyFile);
|
void switchToOpenDatabase(const QString& fileName, const QString& password, const QString& keyFile);
|
||||||
|
void switchToOpenMergeDatabase(const QString& fileName);
|
||||||
|
void switchToOpenMergeDatabase(const QString& fileName, const QString& password, const QString& keyFile);
|
||||||
void switchToImportKeepass1(const QString& fileName);
|
void switchToImportKeepass1(const QString& fileName);
|
||||||
// Search related slots
|
// Search related slots
|
||||||
void search(const QString& searchtext);
|
void search(const QString& searchtext);
|
||||||
@ -132,7 +139,6 @@ public Q_SLOTS:
|
|||||||
private Q_SLOTS:
|
private Q_SLOTS:
|
||||||
void entryActivationSignalReceived(Entry* entry, EntryModel::ModelColumn column);
|
void entryActivationSignalReceived(Entry* entry, EntryModel::ModelColumn column);
|
||||||
void switchBackToEntryEdit();
|
void switchBackToEntryEdit();
|
||||||
void switchToView(bool accepted);
|
|
||||||
void switchToHistoryView(Entry* entry);
|
void switchToHistoryView(Entry* entry);
|
||||||
void switchToEntryEdit(Entry* entry);
|
void switchToEntryEdit(Entry* entry);
|
||||||
void switchToEntryEdit(Entry* entry, bool create);
|
void switchToEntryEdit(Entry* entry, bool create);
|
||||||
@ -141,6 +147,7 @@ private Q_SLOTS:
|
|||||||
void emitEntryContextMenuRequested(const QPoint& pos);
|
void emitEntryContextMenuRequested(const QPoint& pos);
|
||||||
void updateMasterKey(bool accepted);
|
void updateMasterKey(bool accepted);
|
||||||
void openDatabase(bool accepted);
|
void openDatabase(bool accepted);
|
||||||
|
void mergeDatabase(bool accepted);
|
||||||
void unlockDatabase(bool accepted);
|
void unlockDatabase(bool accepted);
|
||||||
void emitCurrentModeChanged();
|
void emitCurrentModeChanged();
|
||||||
void clearLastGroup(Group* group);
|
void clearLastGroup(Group* group);
|
||||||
@ -158,6 +165,7 @@ private:
|
|||||||
ChangeMasterKeyWidget* m_changeMasterKeyWidget;
|
ChangeMasterKeyWidget* m_changeMasterKeyWidget;
|
||||||
DatabaseSettingsWidget* m_databaseSettingsWidget;
|
DatabaseSettingsWidget* m_databaseSettingsWidget;
|
||||||
DatabaseOpenWidget* m_databaseOpenWidget;
|
DatabaseOpenWidget* m_databaseOpenWidget;
|
||||||
|
DatabaseOpenWidget* m_databaseOpenMergeWidget;
|
||||||
KeePass1OpenWidget* m_keepass1OpenWidget;
|
KeePass1OpenWidget* m_keepass1OpenWidget;
|
||||||
UnlockDatabaseWidget* m_unlockDatabaseWidget;
|
UnlockDatabaseWidget* m_unlockDatabaseWidget;
|
||||||
QSplitter* m_splitter;
|
QSplitter* m_splitter;
|
||||||
|
@ -213,6 +213,8 @@ MainWindow::MainWindow()
|
|||||||
SLOT(saveDatabaseAs()));
|
SLOT(saveDatabaseAs()));
|
||||||
connect(m_ui->actionDatabaseClose, SIGNAL(triggered()), m_ui->tabWidget,
|
connect(m_ui->actionDatabaseClose, SIGNAL(triggered()), m_ui->tabWidget,
|
||||||
SLOT(closeDatabase()));
|
SLOT(closeDatabase()));
|
||||||
|
connect(m_ui->actionDatabaseMerge, SIGNAL(triggered()), m_ui->tabWidget,
|
||||||
|
SLOT(mergeDatabase()));
|
||||||
connect(m_ui->actionChangeMasterKey, SIGNAL(triggered()), m_ui->tabWidget,
|
connect(m_ui->actionChangeMasterKey, SIGNAL(triggered()), m_ui->tabWidget,
|
||||||
SLOT(changeMasterKey()));
|
SLOT(changeMasterKey()));
|
||||||
connect(m_ui->actionChangeDatabaseSettings, SIGNAL(triggered()), m_ui->tabWidget,
|
connect(m_ui->actionChangeDatabaseSettings, SIGNAL(triggered()), m_ui->tabWidget,
|
||||||
@ -378,6 +380,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
|
|||||||
m_ui->actionDatabaseSave->setEnabled(true);
|
m_ui->actionDatabaseSave->setEnabled(true);
|
||||||
m_ui->actionDatabaseSaveAs->setEnabled(true);
|
m_ui->actionDatabaseSaveAs->setEnabled(true);
|
||||||
m_ui->actionExportCsv->setEnabled(true);
|
m_ui->actionExportCsv->setEnabled(true);
|
||||||
|
m_ui->actionDatabaseMerge->setEnabled(m_ui->tabWidget->currentIndex() != -1);
|
||||||
|
|
||||||
m_searchWidgetAction->setEnabled(true);
|
m_searchWidgetAction->setEnabled(true);
|
||||||
break;
|
break;
|
||||||
@ -405,6 +408,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
|
|||||||
m_ui->actionDatabaseSave->setEnabled(false);
|
m_ui->actionDatabaseSave->setEnabled(false);
|
||||||
m_ui->actionDatabaseSaveAs->setEnabled(false);
|
m_ui->actionDatabaseSaveAs->setEnabled(false);
|
||||||
m_ui->actionExportCsv->setEnabled(false);
|
m_ui->actionExportCsv->setEnabled(false);
|
||||||
|
m_ui->actionDatabaseMerge->setEnabled(false);
|
||||||
|
|
||||||
m_searchWidgetAction->setEnabled(false);
|
m_searchWidgetAction->setEnabled(false);
|
||||||
break;
|
break;
|
||||||
@ -437,6 +441,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
|
|||||||
m_ui->actionDatabaseSaveAs->setEnabled(false);
|
m_ui->actionDatabaseSaveAs->setEnabled(false);
|
||||||
m_ui->actionDatabaseClose->setEnabled(false);
|
m_ui->actionDatabaseClose->setEnabled(false);
|
||||||
m_ui->actionExportCsv->setEnabled(false);
|
m_ui->actionExportCsv->setEnabled(false);
|
||||||
|
m_ui->actionDatabaseMerge->setEnabled(false);
|
||||||
|
|
||||||
m_searchWidgetAction->setEnabled(false);
|
m_searchWidgetAction->setEnabled(false);
|
||||||
}
|
}
|
||||||
@ -446,6 +451,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
|
|||||||
m_ui->actionDatabaseOpen->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
m_ui->actionDatabaseOpen->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
||||||
m_ui->menuRecentDatabases->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
m_ui->menuRecentDatabases->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
||||||
m_ui->actionImportKeePass1->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
m_ui->actionImportKeePass1->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
||||||
|
m_ui->actionDatabaseMerge->setEnabled(inDatabaseTabWidget);
|
||||||
m_ui->actionRepairDatabase->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
m_ui->actionRepairDatabase->setEnabled(inDatabaseTabWidgetOrWelcomeWidget);
|
||||||
|
|
||||||
m_ui->actionLockDatabases->setEnabled(m_ui->tabWidget->hasLockableDatabases());
|
m_ui->actionLockDatabases->setEnabled(m_ui->tabWidget->hasLockableDatabases());
|
||||||
|
@ -119,6 +119,7 @@
|
|||||||
<addaction name="actionChangeMasterKey"/>
|
<addaction name="actionChangeMasterKey"/>
|
||||||
<addaction name="actionChangeDatabaseSettings"/>
|
<addaction name="actionChangeDatabaseSettings"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
|
<addaction name="actionDatabaseMerge"/>
|
||||||
<addaction name="actionImportKeePass1"/>
|
<addaction name="actionImportKeePass1"/>
|
||||||
<addaction name="actionExportCsv"/>
|
<addaction name="actionExportCsv"/>
|
||||||
<addaction name="actionRepairDatabase"/>
|
<addaction name="actionRepairDatabase"/>
|
||||||
@ -243,6 +244,11 @@
|
|||||||
<string>&New database</string>
|
<string>&New database</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
|
<action name="actionDatabaseMerge">
|
||||||
|
<property name="text">
|
||||||
|
<string>Merge from KeePassX database</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
<action name="actionEntryNew">
|
<action name="actionEntryNew">
|
||||||
<property name="enabled">
|
<property name="enabled">
|
||||||
<bool>false</bool>
|
<bool>false</bool>
|
||||||
|
@ -201,7 +201,7 @@ Only entries with the same scheme (http://, https://, ftp://, ...) are returned<
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
<layout class="QHBoxLayout" name="horizontalLayout_1">
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="label_5">
|
<widget class="QLabel" name="label_5">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
@ -225,7 +225,7 @@ Only entries with the same scheme (http://, https://, ftp://, ...) are returned<
|
|||||||
</layout>
|
</layout>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
<layout class="QHBoxLayout" name="horizontalLayout_2">
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="label_4">
|
<widget class="QLabel" name="label_4">
|
||||||
<property name="sizePolicy">
|
<property name="sizePolicy">
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QSignalSpy>
|
#include <QSignalSpy>
|
||||||
|
#include <QDebug>
|
||||||
#include <QTest>
|
#include <QTest>
|
||||||
|
|
||||||
#include "core/Database.h"
|
#include "core/Database.h"
|
||||||
@ -449,3 +450,120 @@ void TestGroup::testCopyCustomIcons()
|
|||||||
delete dbTarget;
|
delete dbTarget;
|
||||||
delete dbSource;
|
delete dbSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestGroup::testMerge()
|
||||||
|
{
|
||||||
|
Group* group1 = new Group();
|
||||||
|
group1->setName("group 1");
|
||||||
|
Group* group2 = new Group();
|
||||||
|
group2->setName("group 2");
|
||||||
|
|
||||||
|
Entry* entry1 = new Entry();
|
||||||
|
Entry* entry2 = new Entry();
|
||||||
|
|
||||||
|
entry1->setGroup(group1);
|
||||||
|
entry1->setUuid(Uuid::random());
|
||||||
|
entry2->setGroup(group1);
|
||||||
|
entry2->setUuid(Uuid::random());
|
||||||
|
|
||||||
|
group2->merge(group1);
|
||||||
|
|
||||||
|
QCOMPARE(group1->entries().size(), 2);
|
||||||
|
QCOMPARE(group2->entries().size(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestGroup::testMergeDatabase()
|
||||||
|
{
|
||||||
|
Database* dbSource = createMergeTestDatabase();
|
||||||
|
Database* dbDest = new Database();
|
||||||
|
|
||||||
|
dbDest->merge(dbSource);
|
||||||
|
|
||||||
|
QCOMPARE(dbDest->rootGroup()->children().size(), 2);
|
||||||
|
QCOMPARE(dbDest->rootGroup()->children().at(0)->entries().size(), 2);
|
||||||
|
|
||||||
|
delete dbDest;
|
||||||
|
delete dbSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestGroup::testMergeConflict()
|
||||||
|
{
|
||||||
|
Database* dbSource = createMergeTestDatabase();
|
||||||
|
|
||||||
|
// test merging updated entries
|
||||||
|
// falls back to KeepBoth mode
|
||||||
|
Database* dbCopy = new Database();
|
||||||
|
dbCopy->setRootGroup(dbSource->rootGroup()->clone(Entry::CloneNoFlags));
|
||||||
|
|
||||||
|
// sanity check
|
||||||
|
QCOMPARE(dbCopy->rootGroup()->children().at(0)->entries().size(), 2);
|
||||||
|
|
||||||
|
// make this entry newer than in original db
|
||||||
|
Entry* updatedEntry = dbCopy->rootGroup()->children().at(0)->entries().at(0);
|
||||||
|
TimeInfo updatedTimeInfo = updatedEntry->timeInfo();
|
||||||
|
updatedTimeInfo.setLastModificationTime(updatedTimeInfo.lastModificationTime().addYears(1));
|
||||||
|
updatedEntry->setTimeInfo(updatedTimeInfo);
|
||||||
|
|
||||||
|
dbCopy->merge(dbSource);
|
||||||
|
|
||||||
|
// one entry is duplicated because of mode
|
||||||
|
QCOMPARE(dbCopy->rootGroup()->children().at(0)->entries().size(), 2);
|
||||||
|
|
||||||
|
delete dbSource;
|
||||||
|
delete dbCopy;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TestGroup::testMergeConflictKeepBoth()
|
||||||
|
{
|
||||||
|
Database* dbSource = createMergeTestDatabase();
|
||||||
|
|
||||||
|
// test merging updated entries
|
||||||
|
// falls back to KeepBoth mode
|
||||||
|
Database* dbCopy = new Database();
|
||||||
|
dbCopy->setRootGroup(dbSource->rootGroup()->clone(Entry::CloneNoFlags));
|
||||||
|
|
||||||
|
// sanity check
|
||||||
|
QCOMPARE(dbCopy->rootGroup()->children().at(0)->entries().size(), 2);
|
||||||
|
|
||||||
|
// make this entry newer than in original db
|
||||||
|
Entry* updatedEntry = dbCopy->rootGroup()->children().at(0)->entries().at(0);
|
||||||
|
TimeInfo updatedTimeInfo = updatedEntry->timeInfo();
|
||||||
|
updatedTimeInfo.setLastModificationTime(updatedTimeInfo.lastModificationTime().addYears(1));
|
||||||
|
updatedEntry->setTimeInfo(updatedTimeInfo);
|
||||||
|
|
||||||
|
dbCopy->rootGroup()->setMergeMode(Group::MergeMode::KeepBoth);
|
||||||
|
|
||||||
|
dbCopy->merge(dbSource);
|
||||||
|
|
||||||
|
// one entry is duplicated because of mode
|
||||||
|
QCOMPARE(dbCopy->rootGroup()->children().at(0)->entries().size(), 3);
|
||||||
|
// the older entry was merged from the other db as last in the group
|
||||||
|
Entry* olderEntry = dbCopy->rootGroup()->children().at(0)->entries().at(2);
|
||||||
|
QVERIFY2(olderEntry->attributes()->hasKey("merged"), "older entry is marked with an attribute \"merged\"");
|
||||||
|
|
||||||
|
delete dbSource;
|
||||||
|
delete dbCopy;
|
||||||
|
}
|
||||||
|
|
||||||
|
Database* TestGroup::createMergeTestDatabase()
|
||||||
|
{
|
||||||
|
Database* db = new Database();
|
||||||
|
|
||||||
|
Group* group1 = new Group();
|
||||||
|
group1->setName("group 1");
|
||||||
|
Group* group2 = new Group();
|
||||||
|
group2->setName("group 2");
|
||||||
|
|
||||||
|
Entry* entry1 = new Entry();
|
||||||
|
Entry* entry2 = new Entry();
|
||||||
|
|
||||||
|
entry1->setGroup(group1);
|
||||||
|
entry1->setUuid(Uuid::random());
|
||||||
|
entry2->setGroup(group1);
|
||||||
|
entry2->setUuid(Uuid::random());
|
||||||
|
|
||||||
|
group1->setParent(db->rootGroup());
|
||||||
|
group2->setParent(db->rootGroup());
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
#define KEEPASSX_TESTGROUP_H
|
#define KEEPASSX_TESTGROUP_H
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include "core/Database.h"
|
||||||
|
|
||||||
class TestGroup : public QObject
|
class TestGroup : public QObject
|
||||||
{
|
{
|
||||||
@ -33,6 +34,13 @@ private Q_SLOTS:
|
|||||||
void testCopyCustomIcon();
|
void testCopyCustomIcon();
|
||||||
void testClone();
|
void testClone();
|
||||||
void testCopyCustomIcons();
|
void testCopyCustomIcons();
|
||||||
|
void testMerge();
|
||||||
|
void testMergeConflict();
|
||||||
|
void testMergeDatabase();
|
||||||
|
void testMergeConflictKeepBoth();
|
||||||
|
|
||||||
|
private:
|
||||||
|
Database* createMergeTestDatabase();
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // KEEPASSX_TESTGROUP_H
|
#endif // KEEPASSX_TESTGROUP_H
|
||||||
|
BIN
tests/data/MergeDatabase.kdbx
Normal file
BIN
tests/data/MergeDatabase.kdbx
Normal file
Binary file not shown.
@ -29,6 +29,7 @@
|
|||||||
#include <QToolBar>
|
#include <QToolBar>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
|
||||||
#include "config-keepassx-tests.h"
|
#include "config-keepassx-tests.h"
|
||||||
#include "core/Config.h"
|
#include "core/Config.h"
|
||||||
@ -107,6 +108,37 @@ void TestGui::cleanup()
|
|||||||
m_dbWidget = nullptr;
|
m_dbWidget = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TestGui::testMergeDatabase()
|
||||||
|
{
|
||||||
|
// this triggers a warning. Perhaps similar to https://bugreports.qt.io/browse/QTBUG-49623 ?
|
||||||
|
QSignalSpy dbMergeSpy(m_tabWidget->currentWidget(), SIGNAL(databaseMerged(Database*)));
|
||||||
|
|
||||||
|
// set file to merge from
|
||||||
|
fileDialog()->setNextFileName(QString(KEEPASSX_TEST_DATA_DIR).append("/MergeDatabase.kdbx"));
|
||||||
|
triggerAction("actionDatabaseMerge");
|
||||||
|
|
||||||
|
QWidget* databaseOpenMergeWidget = m_mainWindow->findChild<QWidget*>("databaseOpenMergeWidget");
|
||||||
|
QLineEdit* editPasswordMerge = databaseOpenMergeWidget->findChild<QLineEdit*>("editPassword");
|
||||||
|
QVERIFY(editPasswordMerge->isVisible());
|
||||||
|
|
||||||
|
m_tabWidget->currentDatabaseWidget()->setCurrentWidget(databaseOpenMergeWidget);
|
||||||
|
|
||||||
|
QTest::keyClicks(editPasswordMerge, "a");
|
||||||
|
QTest::keyClick(editPasswordMerge, Qt::Key_Enter);
|
||||||
|
|
||||||
|
QTRY_COMPARE(dbMergeSpy.count(), 1);
|
||||||
|
QTRY_VERIFY(m_tabWidget->tabText(m_tabWidget->currentIndex()).contains("*"));
|
||||||
|
|
||||||
|
m_db = m_tabWidget->currentDatabaseWidget()->database();
|
||||||
|
|
||||||
|
// there are seven child groups of the root group
|
||||||
|
QCOMPARE(m_db->rootGroup()->children().size(), 7);
|
||||||
|
// the merged group should contain an entry
|
||||||
|
QCOMPARE(m_db->rootGroup()->children().at(6)->entries().size(), 1);
|
||||||
|
// the General group contains one entry merged from the other db
|
||||||
|
QCOMPARE(m_db->rootGroup()->findChildByName("General")->entries().size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
void TestGui::testTabs()
|
void TestGui::testTabs()
|
||||||
{
|
{
|
||||||
QCOMPARE(m_tabWidget->count(), 1);
|
QCOMPARE(m_tabWidget->count(), 1);
|
||||||
|
@ -38,6 +38,7 @@ private Q_SLOTS:
|
|||||||
void cleanup();
|
void cleanup();
|
||||||
void cleanupTestCase();
|
void cleanupTestCase();
|
||||||
|
|
||||||
|
void testMergeDatabase();
|
||||||
void testTabs();
|
void testTabs();
|
||||||
void testEditEntry();
|
void testEditEntry();
|
||||||
void testAddEntry();
|
void testAddEntry();
|
||||||
|
Loading…
Reference in New Issue
Block a user