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();