diff --git a/share/icons/badges/3_Modified.svg b/share/icons/badges/3_Modified.svg
new file mode 100644
index 0000000000..1a477fac85
--- /dev/null
+++ b/share/icons/badges/3_Modified.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc
index 3e82e172d5..5758a61e95 100644
--- a/share/icons/icons.qrc
+++ b/share/icons/icons.qrc
@@ -195,5 +195,6 @@
badges/0_ShareActive.svg
badges/1_ShareInactive.svg
badges/2_Expired.svg
+ badges/3_Modified.svg
diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index 574dc39671..84f1cd6cd3 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -4362,6 +4362,10 @@ Would you like to overwrite the existing attachment?
Group Path
+
+ This entry has unsaved changes
+
+
EntryPreviewWidget
@@ -10334,15 +10338,19 @@ This option is deprecated, use --set-key-file instead.
- Expired
+ Weak Passwords
- Weak Passwords
+ TOTP Entries
- TOTP Entries
+ Unsaved Changes
+
+
+
+ Expired Entries
diff --git a/src/core/Database.cpp b/src/core/Database.cpp
index 5f57601411..cd66d578e0 100644
--- a/src/core/Database.cpp
+++ b/src/core/Database.cpp
@@ -712,6 +712,7 @@ void Database::setFilePath(const QString& filePath)
if (filePath != m_data.filePath) {
QString oldPath = m_data.filePath;
m_data.filePath = filePath;
+ updateSavedFileModifiedTime();
// Don't watch for changes until the next open or save operation
m_fileWatcher->stop();
m_ignoreFileChangesUntilSaved = false;
@@ -719,6 +720,11 @@ void Database::setFilePath(const QString& filePath)
}
}
+QDateTime Database::savedFileModifiedTime() const
+{
+ return m_savedFileModifiedTime;
+}
+
const QByteArray& Database::fileBlockHash() const
{
return m_fileBlockHash;
@@ -1057,11 +1063,23 @@ void Database::markAsClean()
m_modified = false;
stopModifiedTimer();
m_hasNonDataChange = false;
+ updateSavedFileModifiedTime();
if (emitSignal) {
emit databaseSaved();
}
}
+void Database::updateSavedFileModifiedTime()
+{
+ if (m_data.filePath.isEmpty()) {
+ m_savedFileModifiedTime = {};
+ return;
+ }
+
+ const QFileInfo databaseFile(m_data.filePath);
+ m_savedFileModifiedTime = databaseFile.exists() ? databaseFile.lastModified().toUTC() : QDateTime{};
+}
+
void Database::markNonDataChange()
{
m_hasNonDataChange = true;
diff --git a/src/core/Database.h b/src/core/Database.h
index 0d183e778b..145d3b045c 100644
--- a/src/core/Database.h
+++ b/src/core/Database.h
@@ -110,6 +110,7 @@ class Database : public ModifiableObject
QString filePath() const;
QString canonicalFilePath() const;
void setFilePath(const QString& filePath);
+ QDateTime savedFileModifiedTime() const;
const QByteArray& fileBlockHash() const;
void setIgnoreFileChangesUntilSaved(bool ignore);
@@ -239,6 +240,7 @@ public slots:
void startModifiedTimer();
void stopModifiedTimer();
+ void updateSavedFileModifiedTime();
QByteArray m_fileBlockHash;
bool m_ignoreFileChangesUntilSaved;
@@ -249,6 +251,7 @@ public slots:
QTimer m_modifiedTimer;
QMutex m_saveMutex;
QPointer m_fileWatcher;
+ QDateTime m_savedFileModifiedTime;
bool m_modified = false;
bool m_hasNonDataChange = false;
QString m_keyError;
diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp
index 92404ab3dc..2846100c22 100644
--- a/src/core/Entry.cpp
+++ b/src/core/Entry.cpp
@@ -585,6 +585,18 @@ bool Entry::hasValidTotp() const
return error.isEmpty();
}
+bool Entry::hasUnsavedChanges() const
+{
+ auto db = database();
+ // Basic checks to avoid more expensive file checks later
+ if (!db || !db->isModified() || db->filePath().isEmpty()) {
+ return false;
+ }
+
+ const auto saved = db->savedFileModifiedTime();
+ return saved.isValid() && timeInfo().lastModificationTime() > saved;
+}
+
bool Entry::hasPasskey() const
{
return m_attributes->hasPasskey();
diff --git a/src/core/Entry.h b/src/core/Entry.h
index 4874f59376..e1a6556588 100644
--- a/src/core/Entry.h
+++ b/src/core/Entry.h
@@ -121,6 +121,7 @@ class Entry : public ModifiableObject
const QSharedPointer passwordHealth() const;
bool excludeFromReports() const;
void setExcludeFromReports(bool state);
+ bool hasUnsavedChanges() const;
bool hasPasskey() const;
void removePasskey();
diff --git a/src/core/EntrySearcher.cpp b/src/core/EntrySearcher.cpp
index cb9e135fe3..329701bfc1 100644
--- a/src/core/EntrySearcher.cpp
+++ b/src/core/EntrySearcher.cpp
@@ -218,6 +218,9 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
break;
}
}
+ } else if (term.word.compare("modified", Qt::CaseInsensitive) == 0) {
+ found = entry->hasUnsavedChanges();
+ break;
}
found = false;
break;
diff --git a/src/gui/DatabaseIcons.h b/src/gui/DatabaseIcons.h
index ddcdc2568a..d7d7e95a8b 100644
--- a/src/gui/DatabaseIcons.h
+++ b/src/gui/DatabaseIcons.h
@@ -40,7 +40,8 @@ class DatabaseIcons
{
ShareActive = 0,
ShareInactive,
- Expired
+ Expired,
+ Modified
};
QPixmap icon(int index, IconSize size = IconSize::Default);
diff --git a/src/gui/Icons.cpp b/src/gui/Icons.cpp
index cdb553fb24..f886a7c0c4 100644
--- a/src/gui/Icons.cpp
+++ b/src/gui/Icons.cpp
@@ -246,6 +246,8 @@ QPixmap Icons::entryIconPixmap(const Entry* entry, IconSize size)
if (entry->isExpired()) {
icon = databaseIcons()->applyBadge(icon, DatabaseIcons::Badges::Expired);
+ } else if (entry->hasUnsavedChanges()) {
+ icon = databaseIcons()->applyBadge(icon, DatabaseIcons::Badges::Modified);
}
return icon;
diff --git a/src/gui/entry/EntryModel.cpp b/src/gui/entry/EntryModel.cpp
index 34723485f0..41f611da5a 100644
--- a/src/gui/entry/EntryModel.cpp
+++ b/src/gui/entry/EntryModel.cpp
@@ -326,6 +326,9 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const
if (entry->isExpired()) {
font.setStrikeOut(true);
}
+ if (entry->hasUnsavedChanges()) {
+ font.setItalic(true);
+ }
return font;
} else if (role == Qt::ForegroundRole) {
@@ -360,6 +363,8 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const
} else if (role == Qt::ToolTipRole) {
if (index.column() == PasswordStrength && !entry->password().isEmpty() && !entry->excludeFromReports()) {
return entry->passwordHealth()->scoreReason();
+ } else if (index.column() == Title && entry->hasUnsavedChanges()) {
+ return tr("This entry has unsaved changes");
}
}
diff --git a/src/gui/tag/TagModel.cpp b/src/gui/tag/TagModel.cpp
index a19f263d84..323bb92316 100644
--- a/src/gui/tag/TagModel.cpp
+++ b/src/gui/tag/TagModel.cpp
@@ -20,7 +20,6 @@
#include "core/Database.h"
#include "core/Metadata.h"
#include "gui/Icons.h"
-#include "gui/MessageBox.h"
#include
#include
@@ -28,10 +27,14 @@
TagModel::TagModel(QObject* parent)
: QAbstractListModel(parent)
{
- m_defaultSearches << qMakePair(tr("Clear Search"), QString("")) << qMakePair(tr("All Entries"), QString("*"))
- << qMakePair(tr("Expired"), QString("is:expired"))
+ // clang-format off
+ m_defaultSearches << qMakePair(tr("Clear Search"), QString(""))
+ << qMakePair(tr("All Entries"), QString("*"))
+ << qMakePair(tr("Unsaved Changes"), QString("is:modified"))
+ << qMakePair(tr("Expired Entries"), QString("is:expired"))
<< qMakePair(tr("Weak Passwords"), QString("is:weak"))
<< qMakePair(tr("TOTP Entries"), QString("has:totp"));
+ // clang-format on
}
TagModel::~TagModel() = default;
diff --git a/tests/TestEntryModel.cpp b/tests/TestEntryModel.cpp
index e4677324ce..fb67ae4675 100644
--- a/tests/TestEntryModel.cpp
+++ b/tests/TestEntryModel.cpp
@@ -18,9 +18,15 @@
#include "TestEntryModel.h"
+#include
+#include
+#include
#include
+#include
#include
+#include "core/Config.h"
+#include "core/Database.h"
#include "core/Entry.h"
#include "core/Group.h"
#include "crypto/Crypto.h"
@@ -132,6 +138,42 @@ void TestEntryModel::test()
delete model;
}
+void TestEntryModel::testUnsavedEntryFont()
+{
+ const auto originalAutoSave = config()->get(Config::AutoSaveAfterEveryChange);
+
+ QTemporaryFile dbFile;
+ QVERIFY(dbFile.open());
+ QVERIFY(dbFile.setFileTime(QDateTime::currentDateTimeUtc().addSecs(-60), QFileDevice::FileModificationTime));
+
+ auto db = new Database();
+ db->setFilePath(dbFile.fileName());
+
+ auto entry = new Entry();
+ entry->setTitle("Unsaved");
+ entry->setGroup(db->rootGroup());
+ db->markAsModified();
+
+ auto model = new EntryModel(this);
+ model->setGroup(db->rootGroup());
+
+ auto font = model->data(model->index(0, EntryModel::Title), Qt::FontRole).value();
+ QVERIFY(font.italic());
+
+ config()->set(Config::AutoSaveAfterEveryChange, true);
+ font = model->data(model->index(0, EntryModel::Title), Qt::FontRole).value();
+ QVERIFY(font.italic());
+
+ config()->set(Config::AutoSaveAfterEveryChange, false);
+ db->markAsClean();
+ font = model->data(model->index(0, EntryModel::Title), Qt::FontRole).value();
+ QVERIFY(!font.italic());
+
+ config()->set(Config::AutoSaveAfterEveryChange, originalAutoSave);
+ delete model;
+ delete db;
+}
+
void TestEntryModel::testAttachmentsModel()
{
auto entryAttachments = new EntryAttachments(this);
diff --git a/tests/TestEntryModel.h b/tests/TestEntryModel.h
index df80331e86..09cdd317c6 100644
--- a/tests/TestEntryModel.h
+++ b/tests/TestEntryModel.h
@@ -27,6 +27,7 @@ class TestEntryModel : public QObject
private slots:
void initTestCase();
void test();
+ void testUnsavedEntryFont();
void testAttachmentsModel();
void testAttributesModel();
void testDefaultIconModel();