Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/keepassxreboot/keepassxc.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJonathan White <support@dmapps.us>2022-09-08 02:25:23 +0300
committerJonathan White <support@dmapps.us>2022-09-22 13:49:07 +0300
commitee55143c4a720f2e7ffbce8a2f4ef80299ad7b0f (patch)
tree7f425a8d255e6700b7cbcf536b35af5763ed28ec
parent5b923aee1af6eabdc412c22f2454bdee6d92e71b (diff)
Enhance Tags / Saved Searches
* Rename "Database Tags" to "Searches and Tags" * Separate searching for all entries and resetting the search * Support selecting multiple tags to search against * Fix using escaped quotes in search terms * Make tag searching more precise * Support `is:expired-#` to search for entries expiring within # days. Exclude recycled entries from expired search. * Don't list tags from entries that are recycled * Force hide tag auto-completion menu when tag editing widget is hidden. On rare occasions the focus out signal is not called when the tag view is hidden (entry edit is closed), this resolves that problem. * Remove spaces from before and after tags to prevent seemingly duplicate tags from being created. * Also fix some awkward signal/slot dances that were setup over time with the entry view and preview widget. Allow changing tags for multiple entries through context menu * Closes #8277 - show context menu with currently available tags in database and checks those that are set on one or more selected entries. When a tag is selected it is either set or unset on all entries depending on its checked state. * Add ability to save searches and recall them from the "Searches and Tags" view * Add ability to remove a tag from all entries from the "Searches and Tags" view * Cleanup tag handling and widgets
-rw-r--r--share/translations/keepassxc_en.ts68
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/core/Database.cpp15
-rw-r--r--src/core/Database.h1
-rw-r--r--src/core/Entry.cpp44
-rw-r--r--src/core/Entry.h5
-rw-r--r--src/core/EntrySearcher.cpp29
-rw-r--r--src/core/EntrySearcher.h1
-rw-r--r--src/core/Metadata.cpp24
-rw-r--r--src/core/Metadata.h4
-rw-r--r--src/gui/DatabaseWidget.cpp101
-rw-r--r--src/gui/DatabaseWidget.h8
-rw-r--r--src/gui/EntryPreviewWidget.cpp72
-rw-r--r--src/gui/EntryPreviewWidget.h1
-rw-r--r--src/gui/MainWindow.cpp39
-rw-r--r--src/gui/MainWindow.h2
-rw-r--r--src/gui/MainWindow.ui6
-rw-r--r--src/gui/SearchWidget.cpp8
-rw-r--r--src/gui/SearchWidget.h1
-rw-r--r--src/gui/SearchWidget.ui5
-rw-r--r--src/gui/entry/EditEntryWidgetMain.ui2
-rw-r--r--src/gui/entry/EntryView.cpp9
-rw-r--r--src/gui/entry/EntryView.h1
-rw-r--r--src/gui/tag/TagModel.cpp69
-rw-r--r--src/gui/tag/TagModel.h15
-rw-r--r--src/gui/tag/TagView.cpp98
-rw-r--r--src/gui/tag/TagView.h47
-rw-r--r--src/gui/tag/TagsEdit.cpp7
-rw-r--r--src/gui/tag/TagsEdit.h1
-rw-r--r--tests/TestEntrySearcher.cpp2
30 files changed, 572 insertions, 114 deletions
diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index edba1690f..e36975737 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -2250,10 +2250,6 @@ This is definitely a bug, please report it to the developers.</source>
<context>
<name>DatabaseWidget</name>
<message>
- <source>Database Tags</source>
- <translation type="unfinished"></translation>
- </message>
- <message>
<source>Searching…</source>
<translation type="unfinished"></translation>
</message>
@@ -2417,6 +2413,22 @@ Disable safe saves and try again?</source>
<numerusform></numerusform>
</translation>
</message>
+ <message>
+ <source>Searches and Tags</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>Enter a unique name or overwrite an existing search from the list:</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>Save</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>Save Search</source>
+ <translation type="unfinished"></translation>
+ </message>
</context>
<context>
<name>EditEntryWidget</name>
@@ -5403,6 +5415,21 @@ We recommend you use the AppImage available on our downloads page.</source>
<source>You must restart the application to apply this setting. Would you like to restart now?</source>
<translation type="unfinished"></translation>
</message>
+ <message>
+ <source>Tags</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>No Tags</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message numerus="yes">
+ <source>%1 Entry(s)</source>
+ <translation type="unfinished">
+ <numerusform></numerusform>
+ <numerusform></numerusform>
+ </translation>
+ </message>
</context>
<context>
<name>ManageDatabase</name>
@@ -8372,6 +8399,10 @@ Kernel: %3 %4</source>
<source>Limit search to selected group</source>
<translation type="unfinished"></translation>
</message>
+ <message>
+ <source>Save Search</source>
+ <translation type="unfinished"></translation>
+ </message>
</context>
<context>
<name>SettingsClientModel</name>
@@ -8585,15 +8616,38 @@ Kernel: %3 %4</source>
<context>
<name>TagModel</name>
<message>
- <source>All</source>
+ <source>Expired</source>
<translation type="unfinished"></translation>
</message>
<message>
- <source>Expired</source>
+ <source>Weak Passwords</source>
<translation type="unfinished"></translation>
</message>
<message>
- <source>Weak Passwords</source>
+ <source>All Entries</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>Clear Search</source>
+ <translation type="unfinished"></translation>
+ </message>
+</context>
+<context>
+ <name>TagView</name>
+ <message>
+ <source>Remove Search</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>Remove Tag</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>Confirm Remove Tag</source>
+ <translation type="unfinished"></translation>
+ </message>
+ <message>
+ <source>Remove tag &quot;%1&quot; from all entries in this database?</source>
<translation type="unfinished"></translation>
</message>
</context>
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 4cf802f30..b0bd3e0ab 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -150,6 +150,7 @@ set(keepassx_SOURCES
gui/group/GroupModel.cpp
gui/group/GroupView.cpp
gui/tag/TagModel.cpp
+ gui/tag/TagView.cpp
gui/tag/TagsEdit.cpp
gui/databasekey/KeyComponentWidget.cpp
gui/databasekey/PasswordEditWidget.cpp
diff --git a/src/core/Database.cpp b/src/core/Database.cpp
index 7d92265a2..7a10f0483 100644
--- a/src/core/Database.cpp
+++ b/src/core/Database.cpp
@@ -701,8 +701,8 @@ void Database::updateTagList()
// Search groups recursively looking for tags
// Use a set to prevent adding duplicates
QSet<QString> tagSet;
- for (const auto group : m_rootGroup->groupsRecursive(true)) {
- for (const auto entry : group->entries()) {
+ for (auto entry : m_rootGroup->entriesRecursive()) {
+ if (!entry->isRecycled()) {
for (auto tag : entry->tagList()) {
tagSet.insert(tag);
}
@@ -714,6 +714,17 @@ void Database::updateTagList()
emit tagListUpdated();
}
+void Database::removeTag(const QString& tag)
+{
+ if (!m_rootGroup) {
+ return;
+ }
+
+ for (auto entry : m_rootGroup->entriesRecursive()) {
+ entry->removeTag(tag);
+ }
+}
+
const QUuid& Database::cipher() const
{
return m_data.cipher;
diff --git a/src/core/Database.h b/src/core/Database.h
index bad0b256a..e1bc2ec96 100644
--- a/src/core/Database.h
+++ b/src/core/Database.h
@@ -129,6 +129,7 @@ public:
const QStringList& commonUsernames() const;
const QStringList& tagList() const;
+ void removeTag(const QString& tag);
QSharedPointer<const CompositeKey> key() const;
bool setKey(const QSharedPointer<const CompositeKey>& key,
diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp
index a798033f4..b9a6a4712 100644
--- a/src/core/Entry.cpp
+++ b/src/core/Entry.cpp
@@ -187,15 +187,12 @@ QString Entry::overrideUrl() const
QString Entry::tags() const
{
- return m_data.tags;
+ return m_data.tags.join(",");
}
QStringList Entry::tagList() const
{
- static QRegExp rx("(\\,|\\t|\\;)");
- auto taglist = tags().split(rx, QString::SkipEmptyParts);
- std::sort(taglist.begin(), taglist.end());
- return taglist;
+ return m_data.tags;
}
const TimeInfo& Entry::timeInfo() const
@@ -654,7 +651,42 @@ void Entry::setOverrideUrl(const QString& url)
void Entry::setTags(const QString& tags)
{
- set(m_data.tags, tags);
+ static QRegExp rx("(\\,|\\t|\\;)");
+ auto taglist = tags.split(rx, QString::SkipEmptyParts);
+ // Trim whitespace before/after tag text
+ for (auto itr = taglist.begin(); itr != taglist.end(); ++itr) {
+ *itr = itr->trimmed();
+ }
+ // Remove duplicates
+ auto tagSet = QSet<QString>::fromList(taglist);
+ taglist = tagSet.toList();
+ // Sort alphabetically
+ taglist.sort();
+ set(m_data.tags, taglist);
+}
+
+void Entry::addTag(const QString& tag)
+{
+ auto cleanTag = tag.trimmed();
+ cleanTag.remove(QRegExp("(\\,|\\t|\\;)"));
+
+ auto taglist = m_data.tags;
+ if (!taglist.contains(cleanTag)) {
+ taglist.append(cleanTag);
+ taglist.sort();
+ set(m_data.tags, taglist);
+ }
+}
+
+void Entry::removeTag(const QString& tag)
+{
+ auto cleanTag = tag.trimmed();
+ cleanTag.remove(QRegExp("(\\,|\\t|\\;)"));
+
+ auto taglist = m_data.tags;
+ if (taglist.removeAll(tag) > 0) {
+ set(m_data.tags, taglist);
+ }
}
void Entry::setTimeInfo(const TimeInfo& timeInfo)
diff --git a/src/core/Entry.h b/src/core/Entry.h
index 6984a9845..11eb8fbbc 100644
--- a/src/core/Entry.h
+++ b/src/core/Entry.h
@@ -58,7 +58,7 @@ struct EntryData
QString foregroundColor;
QString backgroundColor;
QString overrideUrl;
- QString tags;
+ QStringList tags;
bool autoTypeEnabled;
int autoTypeObfuscation;
QString defaultAutoTypeSequence;
@@ -158,6 +158,9 @@ public:
void setPreviousParentGroup(const Group* group);
void setPreviousParentGroupUuid(const QUuid& uuid);
+ void addTag(const QString& tag);
+ void removeTag(const QString& tag);
+
QList<Entry*> historyItems();
const QList<Entry*>& historyItems() const;
void addHistoryItem(Entry* entry);
diff --git a/src/core/EntrySearcher.cpp b/src/core/EntrySearcher.cpp
index e52033a04..3292ca112 100644
--- a/src/core/EntrySearcher.cpp
+++ b/src/core/EntrySearcher.cpp
@@ -25,8 +25,6 @@
EntrySearcher::EntrySearcher(bool caseSensitive, bool skipProtected)
: m_caseSensitive(caseSensitive)
, m_skipProtected(skipProtected)
- , m_termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re")
-// Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string
{
}
@@ -197,11 +195,16 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
}
break;
case Field::Tag:
- found = term.regex.match(entry->tags()).hasMatch();
+ found = entry->tagList().indexOf(term.regex) != -1;
break;
case Field::Is:
- if (term.word.compare("expired", Qt::CaseInsensitive) == 0) {
- found = entry->isExpired();
+ if (term.word.startsWith("expired", Qt::CaseInsensitive)) {
+ auto days = 0;
+ auto parts = term.word.split("-", QString::SkipEmptyParts);
+ if (parts.length() >= 2) {
+ days = parts[1].toInt();
+ }
+ found = entry->willExpireInDays(days) && !entry->isRecycled();
break;
} else if (term.word.compare("weak", Qt::CaseInsensitive) == 0) {
if (!entry->excludeFromReports() && !entry->password().isEmpty() && !entry->isExpired()) {
@@ -220,8 +223,7 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
found = term.regex.match(entry->resolvePlaceholder(entry->title())).hasMatch()
|| term.regex.match(entry->resolvePlaceholder(entry->username())).hasMatch()
|| term.regex.match(entry->resolvePlaceholder(entry->url())).hasMatch()
- || term.regex.match(entry->resolvePlaceholder(entry->tags())).hasMatch()
- || term.regex.match(entry->notes()).hasMatch();
+ || entry->tagList().indexOf(term.regex) != -1 || term.regex.match(entry->notes()).hasMatch();
}
// negate the result if exclude:
@@ -246,23 +248,26 @@ void EntrySearcher::parseSearchTerms(const QString& searchString)
{QStringLiteral("notes"), Field::Notes},
{QStringLiteral("pw"), Field::Password},
{QStringLiteral("password"), Field::Password},
- {QStringLiteral("title"), Field::Title},
- {QStringLiteral("t"), Field::Title},
- {QStringLiteral("u"), Field::Username}, // u: stands for username rather than url
+ {QStringLiteral("title"), Field::Title}, // title before tag to capture t:<word>
+ {QStringLiteral("username"), Field::Username}, // username before url to capture u:<word>
{QStringLiteral("url"), Field::Url},
- {QStringLiteral("username"), Field::Username},
{QStringLiteral("group"), Field::Group},
{QStringLiteral("tag"), Field::Tag},
{QStringLiteral("is"), Field::Is}};
+ // Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string
+ static QRegularExpression termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re");
+
m_searchTerms.clear();
- auto results = m_termParser.globalMatch(searchString);
+ auto results = termParser.globalMatch(searchString);
while (results.hasNext()) {
auto result = results.next();
SearchTerm term{};
// Quoted string group
term.word = result.captured(3);
+ // Unescape quotes
+ term.word.replace("\\\"", "\"");
// If empty, use the unquoted string group
if (term.word.isEmpty()) {
diff --git a/src/core/EntrySearcher.h b/src/core/EntrySearcher.h
index 80c86600c..9376d10de 100644
--- a/src/core/EntrySearcher.h
+++ b/src/core/EntrySearcher.h
@@ -71,7 +71,6 @@ private:
bool m_caseSensitive;
bool m_skipProtected;
- QRegularExpression m_termParser;
QList<SearchTerm> m_searchTerms;
friend class TestEntrySearcher;
diff --git a/src/core/Metadata.cpp b/src/core/Metadata.cpp
index d88998057..52a615e28 100644
--- a/src/core/Metadata.cpp
+++ b/src/core/Metadata.cpp
@@ -24,6 +24,7 @@
#include <QApplication>
#include <QCryptographicHash>
+#include <QJsonDocument>
const int Metadata::DefaultHistoryMaxItems = 10;
const int Metadata::DefaultHistoryMaxSize = 6 * 1024 * 1024;
@@ -487,3 +488,26 @@ void Metadata::setSettingsChanged(const QDateTime& value)
Q_ASSERT(value.timeSpec() == Qt::UTC);
m_settingsChanged = value;
}
+
+void Metadata::addSavedSearch(const QString& name, const QString& searchtext)
+{
+ auto searches = savedSearches();
+ searches.insert(name, searchtext);
+ auto json = QJsonDocument::fromVariant(searches);
+ m_customData->set("KPXC_SavedSearch", json.toJson());
+}
+
+void Metadata::deleteSavedSearch(const QString& name)
+{
+ auto searches = savedSearches();
+ searches.remove(name);
+ auto json = QJsonDocument::fromVariant(searches);
+ m_customData->set("KPXC_SavedSearch", json.toJson());
+}
+
+QVariantMap Metadata::savedSearches()
+{
+ auto searches = m_customData->value("KPXC_SavedSearch");
+ auto json = QJsonDocument::fromJson(searches.toUtf8());
+ return json.toVariant().toMap();
+}
diff --git a/src/core/Metadata.h b/src/core/Metadata.h
index 61c9c1e6e..ccefdb1c8 100644
--- a/src/core/Metadata.h
+++ b/src/core/Metadata.h
@@ -23,6 +23,7 @@
#include <QHash>
#include <QPointer>
#include <QUuid>
+#include <QVariantMap>
#include "core/CustomData.h"
#include "core/Global.h"
@@ -150,6 +151,9 @@ public:
void setHistoryMaxItems(int value);
void setHistoryMaxSize(int value);
void setUpdateDatetime(bool value);
+ void addSavedSearch(const QString& name, const QString& searchtext);
+ void deleteSavedSearch(const QString& name);
+ QVariantMap savedSearches();
/*
* Copy all attributes from other except:
* - Group pointers/uuids
diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp
index b6e6b3749..9c69b7ee1 100644
--- a/src/gui/DatabaseWidget.cpp
+++ b/src/gui/DatabaseWidget.cpp
@@ -23,6 +23,7 @@
#include <QCheckBox>
#include <QDesktopServices>
#include <QHostInfo>
+#include <QInputDialog>
#include <QKeyEvent>
#include <QPlainTextEdit>
#include <QProcess>
@@ -50,7 +51,7 @@
#include "gui/group/EditGroupWidget.h"
#include "gui/group/GroupView.h"
#include "gui/reports/ReportsDialog.h"
-#include "gui/tag/TagModel.h"
+#include "gui/tag/TagView.h"
#include "keeshare/KeeShare.h"
#ifdef WITH_XC_NETWORKING
@@ -82,7 +83,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer<Database> db, QWidget* parent)
, m_keepass1OpenWidget(new KeePass1OpenWidget(this))
, m_opVaultOpenWidget(new OpVaultOpenWidget(this))
, m_groupView(new GroupView(m_db.data(), this))
- , m_tagView(new QListView(this))
+ , m_tagView(new TagView(this))
, m_saveAttempts(0)
, m_entrySearcher(new EntrySearcher(false))
{
@@ -97,20 +98,15 @@ DatabaseWidget::DatabaseWidget(QSharedPointer<Database> db, QWidget* parent)
hbox->addWidget(m_mainSplitter);
m_mainWidget->setLayout(mainLayout);
- // Setup tags view and place under groups
- auto tagModel = new TagModel(m_db);
+ // Setup searches and tags view and place under groups
m_tagView->setObjectName("tagView");
- m_tagView->setModel(tagModel);
- m_tagView->setFrameStyle(QFrame::NoFrame);
- m_tagView->setSelectionMode(QListView::SingleSelection);
- m_tagView->setSelectionBehavior(QListView::SelectRows);
- m_tagView->setCurrentIndex(tagModel->index(0));
- connect(m_tagView, SIGNAL(activated(QModelIndex)), this, SLOT(filterByTag(QModelIndex)));
- connect(m_tagView, SIGNAL(clicked(QModelIndex)), this, SLOT(filterByTag(QModelIndex)));
+ m_tagView->setDatabase(m_db);
+ connect(m_tagView, SIGNAL(activated(QModelIndex)), this, SLOT(filterByTag()));
+ connect(m_tagView, SIGNAL(clicked(QModelIndex)), this, SLOT(filterByTag()));
auto tagsWidget = new QWidget();
auto tagsLayout = new QVBoxLayout();
- auto tagsTitle = new QLabel(tr("Database Tags"));
+ auto tagsTitle = new QLabel(tr("Searches and Tags"));
tagsTitle->setProperty("title", true);
tagsWidget->setObjectName("tagWidget");
tagsWidget->setLayout(tagsLayout);
@@ -206,13 +202,6 @@ DatabaseWidget::DatabaseWidget(QSharedPointer<Database> db, QWidget* parent)
connect(m_groupView, SIGNAL(groupSelectionChanged()), SLOT(onGroupChanged()));
connect(m_groupView, SIGNAL(groupSelectionChanged()), SIGNAL(groupChanged()));
connect(m_groupView, &GroupView::groupFocused, this, [this] { m_previewView->setGroup(currentGroup()); });
- connect(m_entryView, &EntryView::entrySelectionChanged, this, [this](Entry * currentEntry) {
- if (currentEntry) {
- m_previewView->setEntry(currentEntry);
- } else {
- m_previewView->setGroup(groupView()->currentGroup());
- }
- });
connect(m_entryView, SIGNAL(entryActivated(Entry*,EntryModel::ModelColumn)),
SLOT(entryActivationSignalReceived(Entry*,EntryModel::ModelColumn)));
connect(m_entryView, SIGNAL(entrySelectionChanged(Entry*)), SLOT(onEntryChanged(Entry*)));
@@ -431,8 +420,7 @@ void DatabaseWidget::replaceDatabase(QSharedPointer<Database> db)
m_db = std::move(db);
connectDatabaseSignals();
m_groupView->changeDatabase(m_db);
- auto tagModel = new TagModel(m_db);
- m_tagView->setModel(tagModel);
+ m_tagView->setDatabase(m_db);
// Restore the new parent group pointer, if not found default to the root group
// this prevents data loss when merging a database while creating a new entry
@@ -690,11 +678,23 @@ void DatabaseWidget::copyAttribute(QAction* action)
}
}
-void DatabaseWidget::filterByTag(const QModelIndex& index)
+void DatabaseWidget::filterByTag()
{
- m_tagView->selectionModel()->setCurrentIndex(index, QItemSelectionModel::Select);
- const auto model = static_cast<TagModel*>(m_tagView->model());
- emit requestSearch(model->data(index, Qt::UserRole).toString());
+ QStringList searchTerms;
+ const auto selections = m_tagView->selectionModel()->selectedIndexes();
+ for (const auto& index : selections) {
+ searchTerms << index.data(Qt::UserRole).toString();
+ }
+ emit requestSearch(searchTerms.join(" "));
+}
+
+void DatabaseWidget::setTag(QAction* action)
+{
+ auto tag = action->text();
+ auto state = action->isChecked();
+ for (auto entry : m_entryView->selectedEntries()) {
+ state ? entry->addTag(tag) : entry->removeTag(tag);
+ }
}
void DatabaseWidget::showTotpKeyQrCode()
@@ -1128,22 +1128,13 @@ void DatabaseWidget::loadDatabase(bool accepted)
// Only show expired entries if first unlock and option is enabled
if (m_groupBeforeLock.isNull() && config()->get(Config::GUI_ShowExpiredEntriesOnDatabaseUnlock).toBool()) {
int expirationOffset = config()->get(Config::GUI_ShowExpiredEntriesOnDatabaseUnlockOffsetDays).toInt();
- QList<Entry*> expiredEntries;
- for (auto entry : m_db->rootGroup()->entriesRecursive()) {
- if (entry->willExpireInDays(expirationOffset) && !entry->excludeFromReports() && !entry->isRecycled()) {
- expiredEntries << entry;
- }
- }
-
- if (!expiredEntries.isEmpty()) {
- m_entryView->displaySearch(expiredEntries);
- m_entryView->setFirstEntryActive();
+ requestSearch(QString("is:expired-%1").arg(expirationOffset));
+ QTimer::singleShot(150, this, [=] {
m_searchingLabel->setText(
expirationOffset == 0
? tr("Expired entries")
: tr("Entries expiring within %1 day(s)", "", expirationOffset).arg(expirationOffset));
- m_searchingLabel->setVisible(true);
- }
+ });
}
m_groupBeforeLock = QUuid();
@@ -1449,6 +1440,40 @@ void DatabaseWidget::search(const QString& searchtext)
emit searchModeActivated();
}
+void DatabaseWidget::saveSearch(const QString& searchtext)
+{
+ if (!m_db->isInitialized()) {
+ return;
+ }
+
+ // Pull the existing searches and prepend an empty string to allow
+ // the user to input a new search name without seeing the first one
+ QStringList searches(m_db->metadata()->savedSearches().keys());
+ searches.prepend("");
+
+ QInputDialog dialog(this);
+ connect(this, &DatabaseWidget::databaseLockRequested, &dialog, &QInputDialog::reject);
+
+ dialog.setComboBoxEditable(true);
+ dialog.setComboBoxItems(searches);
+ dialog.setOkButtonText(tr("Save"));
+ dialog.setLabelText(tr("Enter a unique name or overwrite an existing search from the list:"));
+ dialog.setWindowTitle(tr("Save Search"));
+ dialog.exec();
+
+ auto name = dialog.textValue();
+ if (!name.isEmpty()) {
+ m_db->metadata()->addSavedSearch(name, searchtext);
+ }
+}
+
+void DatabaseWidget::deleteSearch(const QString& name)
+{
+ if (m_db->isInitialized()) {
+ m_db->metadata()->deleteSavedSearch(name);
+ }
+}
+
void DatabaseWidget::setSearchCaseSensitive(bool state)
{
m_entrySearcher->setCaseSensitive(state);
@@ -1539,6 +1564,8 @@ void DatabaseWidget::onEntryChanged(Entry* entry)
{
if (entry) {
m_previewView->setEntry(entry);
+ } else {
+ m_previewView->setGroup(groupView()->currentGroup());
}
emit entrySelectionChanged();
diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h
index d77a38dd7..ede5a5fbf 100644
--- a/src/gui/DatabaseWidget.h
+++ b/src/gui/DatabaseWidget.h
@@ -49,6 +49,7 @@ class QSplitter;
class QLabel;
class MessageWidget;
class EntryPreviewWidget;
+class TagView;
namespace Ui
{
@@ -175,7 +176,8 @@ public slots:
void copyURL();
void copyNotes();
void copyAttribute(QAction* action);
- void filterByTag(const QModelIndex& index);
+ void filterByTag();
+ void setTag(QAction* action);
void showTotp();
void showTotpKeyQrCode();
void copyTotp();
@@ -218,6 +220,8 @@ public slots:
// Search related slots
void search(const QString& searchtext);
+ void saveSearch(const QString& searchtext);
+ void deleteSearch(const QString& name);
void setSearchCaseSensitive(bool state);
void setSearchLimitGroup(bool state);
void endSearch();
@@ -283,7 +287,7 @@ private:
QPointer<KeePass1OpenWidget> m_keepass1OpenWidget;
QPointer<OpVaultOpenWidget> m_opVaultOpenWidget;
QPointer<GroupView> m_groupView;
- QPointer<QListView> m_tagView;
+ QPointer<TagView> m_tagView;
QPointer<EntryView> m_entryView;
QScopedPointer<Group> m_newGroup;
diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp
index 5fb3d3406..d7017a9b4 100644
--- a/src/gui/EntryPreviewWidget.cpp
+++ b/src/gui/EntryPreviewWidget.cpp
@@ -115,48 +115,72 @@ void EntryPreviewWidget::clear()
void EntryPreviewWidget::setEntry(Entry* selectedEntry)
{
+ disconnect(m_currentEntry);
+ disconnect(m_currentGroup);
+
+ m_currentEntry = selectedEntry;
+ m_currentGroup = nullptr;
+
if (!selectedEntry) {
hide();
return;
}
- m_currentEntry = selectedEntry;
-
- updateEntryHeaderLine();
- updateEntryTotp();
- updateEntryGeneralTab();
- updateEntryAdvancedTab();
- updateEntryAutotypeTab();
-
- setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool());
-
- m_ui->stackedWidget->setCurrentWidget(m_ui->pageEntry);
- const int tabIndex = m_ui->entryTabWidget->isTabEnabled(m_selectedTabEntry) ? m_selectedTabEntry : GeneralTabIndex;
- Q_ASSERT(m_ui->entryTabWidget->isTabEnabled(GeneralTabIndex));
- m_ui->entryTabWidget->setCurrentIndex(tabIndex);
+ connect(selectedEntry, &Entry::modified, this, &EntryPreviewWidget::refresh);
+ refresh();
}
void EntryPreviewWidget::setGroup(Group* selectedGroup)
{
+ disconnect(m_currentEntry);
+ disconnect(m_currentGroup);
+
+ m_currentEntry = nullptr;
+ m_currentGroup = selectedGroup;
+
if (!selectedGroup) {
hide();
return;
}
- m_currentGroup = selectedGroup;
- updateGroupHeaderLine();
- updateGroupGeneralTab();
+ connect(m_currentGroup, &Group::modified, this, &EntryPreviewWidget::refresh);
+ refresh();
+}
+
+void EntryPreviewWidget::refresh()
+{
+ if (m_currentEntry) {
+ updateEntryHeaderLine();
+ updateEntryTotp();
+ updateEntryGeneralTab();
+ updateEntryAdvancedTab();
+ updateEntryAutotypeTab();
+
+ setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool());
+
+ m_ui->stackedWidget->setCurrentWidget(m_ui->pageEntry);
+ const int tabIndex =
+ m_ui->entryTabWidget->isTabEnabled(m_selectedTabEntry) ? m_selectedTabEntry : GeneralTabIndex;
+ Q_ASSERT(m_ui->entryTabWidget->isTabEnabled(GeneralTabIndex));
+ m_ui->entryTabWidget->setCurrentIndex(tabIndex);
+ } else if (m_currentGroup) {
+ updateGroupHeaderLine();
+ updateGroupGeneralTab();
#if defined(WITH_XC_KEESHARE)
- updateGroupSharingTab();
+ updateGroupSharingTab();
#endif
- setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool());
+ setVisible(!config()->get(Config::GUI_HidePreviewPanel).toBool());
- m_ui->stackedWidget->setCurrentWidget(m_ui->pageGroup);
- const int tabIndex = m_ui->groupTabWidget->isTabEnabled(m_selectedTabGroup) ? m_selectedTabGroup : GeneralTabIndex;
- Q_ASSERT(m_ui->groupTabWidget->isTabEnabled(GeneralTabIndex));
- m_ui->groupTabWidget->setCurrentIndex(tabIndex);
+ m_ui->stackedWidget->setCurrentWidget(m_ui->pageGroup);
+ const int tabIndex =
+ m_ui->groupTabWidget->isTabEnabled(m_selectedTabGroup) ? m_selectedTabGroup : GeneralTabIndex;
+ Q_ASSERT(m_ui->groupTabWidget->isTabEnabled(GeneralTabIndex));
+ m_ui->groupTabWidget->setCurrentIndex(tabIndex);
+ } else {
+ hide();
+ }
}
void EntryPreviewWidget::setDatabaseMode(DatabaseWidget::Mode mode)
@@ -240,6 +264,8 @@ void EntryPreviewWidget::setNotesVisible(QTextEdit* notesWidget, const QString&
} else {
if (!notes.isEmpty()) {
notesWidget->setPlainText(QString("\u25cf").repeated(6));
+ } else {
+ notesWidget->setPlainText("");
}
}
}
diff --git a/src/gui/EntryPreviewWidget.h b/src/gui/EntryPreviewWidget.h
index 8a5b0c09f..a6a8d0ca4 100644
--- a/src/gui/EntryPreviewWidget.h
+++ b/src/gui/EntryPreviewWidget.h
@@ -40,6 +40,7 @@ public slots:
void setEntry(Entry* selectedEntry);
void setGroup(Group* selectedGroup);
void setDatabaseMode(DatabaseWidget::Mode mode);
+ void refresh();
void clear();
signals:
diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp
index cab6a5a4c..1b6ccd2d7 100644
--- a/src/gui/MainWindow.cpp
+++ b/src/gui/MainWindow.cpp
@@ -129,6 +129,7 @@ MainWindow::MainWindow()
m_entryContextMenu->addAction(m_ui->actionEntryCopyPassword);
m_entryContextMenu->addAction(m_ui->menuEntryCopyAttribute->menuAction());
m_entryContextMenu->addAction(m_ui->menuEntryTotp->menuAction());
+ m_entryContextMenu->addAction(m_ui->menuTags->menuAction());
m_entryContextMenu->addSeparator();
m_entryContextMenu->addAction(m_ui->actionEntryAutoType);
m_entryContextMenu->addSeparator();
@@ -240,6 +241,11 @@ MainWindow::MainWindow()
m_copyAdditionalAttributeActions, SIGNAL(triggered(QAction*)), SLOT(copyAttribute(QAction*)));
connect(m_ui->menuEntryCopyAttribute, SIGNAL(aboutToShow()), this, SLOT(updateCopyAttributesMenu()));
+ m_setTagsMenuActions = new QActionGroup(m_ui->menuTags);
+ m_setTagsMenuActions->setExclusive(false);
+ m_actionMultiplexer.connect(m_setTagsMenuActions, SIGNAL(triggered(QAction*)), SLOT(setTag(QAction*)));
+ connect(m_ui->menuTags, &QMenu::aboutToShow, this, &MainWindow::updateSetTagsMenu);
+
Qt::Key globalAutoTypeKey = static_cast<Qt::Key>(config()->get(Config::GlobalAutoTypeKey).toInt());
Qt::KeyboardModifiers globalAutoTypeModifiers =
static_cast<Qt::KeyboardModifiers>(config()->get(Config::GlobalAutoTypeModifiers).toInt());
@@ -791,6 +797,38 @@ void MainWindow::updateCopyAttributesMenu()
}
}
+void MainWindow::updateSetTagsMenu()
+{
+ // Remove all existing actions
+ m_ui->menuTags->clear();
+
+ auto dbWidget = m_ui->tabWidget->currentDatabaseWidget();
+ if (dbWidget) {
+ // Enumerate tags applied to the selected entries
+ QSet<QString> selectedTags;
+ for (auto entry : dbWidget->entryView()->selectedEntries()) {
+ for (auto tag : entry->tagList()) {
+ selectedTags.insert(tag);
+ }
+ }
+
+ // Add known database tags as actions and set checked if
+ // a selected entry has that tag
+ for (auto tag : dbWidget->database()->tagList()) {
+ auto action = m_ui->menuTags->addAction(icons()->icon("tag"), tag);
+ action->setCheckable(true);
+ action->setChecked(selectedTags.contains(tag));
+ m_setTagsMenuActions->addAction(action);
+ }
+ }
+
+ // If no tags exist in the database then show a tip to the user
+ if (m_ui->menuTags->isEmpty()) {
+ auto action = m_ui->menuTags->addAction(tr("No Tags"));
+ action->setEnabled(false);
+ }
+}
+
void MainWindow::openRecentDatabase(QAction* action)
{
openDatabase(action->data().toString());
@@ -870,6 +908,7 @@ void MainWindow::setMenuActionState(DatabaseWidget::Mode mode)
m_ui->actionEntryCopyNotes->setEnabled(singleEntrySelected && dbWidget->currentEntryHasNotes());
m_ui->menuEntryCopyAttribute->setEnabled(singleEntrySelected);
m_ui->menuEntryTotp->setEnabled(singleEntrySelected);
+ m_ui->menuTags->setEnabled(entriesSelected);
m_ui->actionEntryAutoType->setEnabled(singleEntrySelected);
m_ui->actionEntryAutoType->menu()->setEnabled(singleEntrySelected);
m_ui->actionEntryAutoTypeSequence->setText(
diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h
index 8102fe0a1..8c4821b3e 100644
--- a/src/gui/MainWindow.h
+++ b/src/gui/MainWindow.h
@@ -130,6 +130,7 @@ private slots:
void clearLastDatabases();
void updateLastDatabasesMenu();
void updateCopyAttributesMenu();
+ void updateSetTagsMenu();
void showEntryContextMenu(const QPoint& globalPos);
void showGroupContextMenu(const QPoint& globalPos);
void applySettingsChanges();
@@ -172,6 +173,7 @@ private:
QPointer<QMenu> m_entryNewContextMenu;
QPointer<QActionGroup> m_lastDatabasesActions;
QPointer<QActionGroup> m_copyAdditionalAttributeActions;
+ QPointer<QActionGroup> m_setTagsMenuActions;
QPointer<InactivityTimer> m_inactivityTimer;
QPointer<InactivityTimer> m_touchIDinactivityTimer;
int m_countDefaultAttributes;
diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui
index b92b00742..86f400f3c 100644
--- a/src/gui/MainWindow.ui
+++ b/src/gui/MainWindow.ui
@@ -316,6 +316,11 @@
<addaction name="actionEntryTotpQRCode"/>
<addaction name="actionEntrySetupTotp"/>
</widget>
+ <widget class="QMenu" name="menuTags">
+ <property name="title">
+ <string>Tags</string>
+ </property>
+ </widget>
<addaction name="actionEntryNew"/>
<addaction name="actionEntryEdit"/>
<addaction name="actionEntryClone"/>
@@ -328,6 +333,7 @@
<addaction name="actionEntryCopyPassword"/>
<addaction name="menuEntryCopyAttribute"/>
<addaction name="menuEntryTotp"/>
+ <addaction name="menuTags"/>
<addaction name="separator"/>
<addaction name="actionEntryAutoType"/>
<addaction name="separator"/>
diff --git a/src/gui/SearchWidget.cpp b/src/gui/SearchWidget.cpp
index ab79868aa..cfeba57c1 100644
--- a/src/gui/SearchWidget.cpp
+++ b/src/gui/SearchWidget.cpp
@@ -46,6 +46,7 @@ SearchWidget::SearchWidget(QWidget* parent)
connect(m_ui->searchEdit, SIGNAL(textChanged(QString)), SLOT(startSearchTimer()));
connect(m_ui->helpIcon, SIGNAL(triggered()), SLOT(toggleHelp()));
connect(m_ui->searchIcon, SIGNAL(triggered()), SLOT(showSearchMenu()));
+ connect(m_ui->saveIcon, &QAction::triggered, this, [this] { emit saveSearch(m_ui->searchEdit->text()); });
connect(m_searchTimer, SIGNAL(timeout()), SLOT(startSearch()));
connect(m_clearSearchTimer, SIGNAL(timeout()), SLOT(clearSearch()));
connect(this, SIGNAL(escapePressed()), SLOT(clearSearch()));
@@ -70,6 +71,10 @@ SearchWidget::SearchWidget(QWidget* parent)
m_ui->helpIcon->setIcon(icons()->icon("system-help"));
m_ui->searchEdit->addAction(m_ui->helpIcon, QLineEdit::TrailingPosition);
+ m_ui->saveIcon->setIcon(icons()->icon("document-save"));
+ m_ui->searchEdit->addAction(m_ui->saveIcon, QLineEdit::TrailingPosition);
+ m_ui->saveIcon->setVisible(false);
+
// Fix initial visibility of actions (bug in Qt)
for (QToolButton* toolButton : m_ui->searchEdit->findChildren<QToolButton*>()) {
toolButton->setVisible(toolButton->defaultAction()->isVisible());
@@ -126,6 +131,7 @@ void SearchWidget::connectSignals(SignalMultiplexer& mx)
{
// Connects basically only to the current DatabaseWidget, but allows to switch between instances!
mx.connect(this, SIGNAL(search(QString)), SLOT(search(QString)));
+ mx.connect(this, SIGNAL(saveSearch(QString)), SLOT(saveSearch(QString)));
mx.connect(this, SIGNAL(caseSensitiveChanged(bool)), SLOT(setSearchCaseSensitive(bool)));
mx.connect(this, SIGNAL(limitGroupChanged(bool)), SLOT(setSearchLimitGroup(bool)));
mx.connect(this, SIGNAL(copyPressed()), SLOT(copyPassword()));
@@ -165,6 +171,7 @@ void SearchWidget::startSearch()
m_searchTimer->stop();
}
+ m_ui->saveIcon->setVisible(true);
search(m_ui->searchEdit->text());
}
@@ -208,6 +215,7 @@ void SearchWidget::focusSearch()
void SearchWidget::clearSearch()
{
m_ui->searchEdit->clear();
+ m_ui->saveIcon->setVisible(false);
emit searchCanceled();
}
diff --git a/src/gui/SearchWidget.h b/src/gui/SearchWidget.h
index 820e9fea8..55edad583 100644
--- a/src/gui/SearchWidget.h
+++ b/src/gui/SearchWidget.h
@@ -61,6 +61,7 @@ signals:
void downPressed();
void enterPressed();
void lostFocus();
+ void saveSearch(const QString& text);
public slots:
void databaseChanged(DatabaseWidget* dbWidget = nullptr);
diff --git a/src/gui/SearchWidget.ui b/src/gui/SearchWidget.ui
index c924b4076..ab4ef1302 100644
--- a/src/gui/SearchWidget.ui
+++ b/src/gui/SearchWidget.ui
@@ -56,6 +56,11 @@
<string>Search Help</string>
</property>
</action>
+ <action name="saveIcon">
+ <property name="text">
+ <string>Save Search</string>
+ </property>
+ </action>
</widget>
<tabstops>
<tabstop>searchEdit</tabstop>
diff --git a/src/gui/entry/EditEntryWidgetMain.ui b/src/gui/entry/EditEntryWidgetMain.ui
index 6b0f95178..894f56115 100644
--- a/src/gui/entry/EditEntryWidgetMain.ui
+++ b/src/gui/entry/EditEntryWidgetMain.ui
@@ -320,8 +320,8 @@
<tabstop>usernameComboBox</tabstop>
<tabstop>passwordEdit</tabstop>
<tabstop>urlEdit</tabstop>
- <tabstop>tagsList</tabstop>
<tabstop>fetchFaviconButton</tabstop>
+ <tabstop>tagsList</tabstop>
<tabstop>expireCheck</tabstop>
<tabstop>expireDatePicker</tabstop>
<tabstop>expirePresets</tabstop>
diff --git a/src/gui/entry/EntryView.cpp b/src/gui/entry/EntryView.cpp
index 09362e391..67a9698b7 100644
--- a/src/gui/entry/EntryView.cpp
+++ b/src/gui/entry/EntryView.cpp
@@ -263,6 +263,15 @@ Entry* EntryView::currentEntry()
}
}
+QList<Entry*> EntryView::selectedEntries()
+{
+ QList<Entry*> list;
+ for (auto row : selectionModel()->selectedRows()) {
+ list.append(m_model->entryFromIndex(m_sortModel->mapToSource(row)));
+ }
+ return list;
+}
+
int EntryView::numberOfSelectedEntries()
{
return selectionModel()->selectedRows().size();
diff --git a/src/gui/entry/EntryView.h b/src/gui/entry/EntryView.h
index 90f37abfc..c7136383a 100644
--- a/src/gui/entry/EntryView.h
+++ b/src/gui/entry/EntryView.h
@@ -38,6 +38,7 @@ public:
void setModel(QAbstractItemModel* model) override;
Entry* currentEntry();
void setCurrentEntry(Entry* entry);
+ QList<Entry*> selectedEntries();
Entry* entryFromIndex(const QModelIndex& index);
QModelIndex indexFromEntry(Entry* entry);
int currentEntryIndex();
diff --git a/src/gui/tag/TagModel.cpp b/src/gui/tag/TagModel.cpp
index 023cb3498..99f253270 100644
--- a/src/gui/tag/TagModel.cpp
+++ b/src/gui/tag/TagModel.cpp
@@ -18,12 +18,19 @@
#include "TagModel.h"
#include "core/Database.h"
+#include "core/Metadata.h"
#include "gui/Icons.h"
+#include "gui/MessageBox.h"
-TagModel::TagModel(QSharedPointer<Database> db, QObject* parent)
+#include <QApplication>
+#include <QMenu>
+
+TagModel::TagModel(QObject* parent)
: QAbstractListModel(parent)
{
- setDatabase(db);
+ m_defaultSearches << qMakePair(tr("Clear Search"), QString("")) << qMakePair(tr("All Entries"), QString("*"))
+ << qMakePair(tr("Expired"), QString("is:expired"))
+ << qMakePair(tr("Weak Passwords"), QString("is:weak"));
}
TagModel::~TagModel()
@@ -32,12 +39,19 @@ TagModel::~TagModel()
void TagModel::setDatabase(QSharedPointer<Database> db)
{
+ if (m_db) {
+ disconnect(m_db.data());
+ }
+
m_db = db;
if (!m_db) {
m_tagList.clear();
return;
}
+
connect(m_db.data(), SIGNAL(tagListUpdated()), SLOT(updateTagList()));
+ connect(m_db->metadata()->customData(), SIGNAL(modified()), SLOT(updateTagList()));
+
updateTagList();
}
@@ -45,10 +59,35 @@ void TagModel::updateTagList()
{
beginResetModel();
m_tagList.clear();
- m_tagList << tr("All") << tr("Expired") << tr("Weak Passwords") << m_db->tagList();
+
+ m_tagList << m_defaultSearches;
+
+ auto savedSearches = m_db->metadata()->savedSearches();
+ for (auto search : savedSearches.keys()) {
+ m_tagList << qMakePair(search, savedSearches[search].toString());
+ }
+
+ m_tagListStart = m_tagList.size();
+ for (auto tag : m_db->tagList()) {
+ auto escapedTag = tag;
+ escapedTag.replace("\"", "\\\"");
+ m_tagList << qMakePair(tag, QString("tag:\"%1\"").arg(escapedTag));
+ }
+
endResetModel();
}
+TagModel::TagType TagModel::itemType(const QModelIndex& index)
+{
+ int row = index.row();
+ if (row < m_defaultSearches.size()) {
+ return TagType::DEFAULT_SEARCH;
+ } else if (row < m_tagListStart) {
+ return TagType::SAVED_SEARCH;
+ }
+ return TagType::TAG;
+}
+
int TagModel::rowCount(const QModelIndex& parent) const
{
Q_UNUSED(parent);
@@ -61,29 +100,23 @@ QVariant TagModel::data(const QModelIndex& index, int role) const
return {};
}
+ const auto row = index.row();
switch (role) {
case Qt::DecorationRole:
- if (index.row() <= 2) {
- return icons()->icon("tag-search");
+ if (row < m_tagListStart) {
+ return icons()->icon("database-search");
}
return icons()->icon("tag");
case Qt::DisplayRole:
- return m_tagList.at(index.row());
+ return m_tagList.at(row).first;
case Qt::UserRole:
- if (index.row() == 0) {
- return "";
- } else if (index.row() == 1) {
- return "is:expired";
- } else if (index.row() == 2) {
- return "is:weak";
+ return m_tagList.at(row).second;
+ case Qt::UserRole + 1:
+ if (row == (m_defaultSearches.size() - 1)) {
+ return true;
}
- return QString("tag:%1").arg(m_tagList.at(index.row()));
+ return false;
}
return {};
}
-
-const QStringList& TagModel::tags() const
-{
- return m_tagList;
-}
diff --git a/src/gui/tag/TagModel.h b/src/gui/tag/TagModel.h
index 020f621f0..8eee0101b 100644
--- a/src/gui/tag/TagModel.h
+++ b/src/gui/tag/TagModel.h
@@ -28,21 +28,30 @@ class TagModel : public QAbstractListModel
Q_OBJECT
public:
- explicit TagModel(QSharedPointer<Database> db, QObject* parent = nullptr);
+ explicit TagModel(QObject* parent = nullptr);
~TagModel() override;
void setDatabase(QSharedPointer<Database> db);
- const QStringList& tags() const;
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ enum TagType
+ {
+ DEFAULT_SEARCH,
+ SAVED_SEARCH,
+ TAG
+ };
+ TagType itemType(const QModelIndex& index);
+
private slots:
void updateTagList();
private:
QSharedPointer<Database> m_db;
- QStringList m_tagList;
+ QList<QPair<QString, QString>> m_defaultSearches;
+ QList<QPair<QString, QString>> m_tagList;
+ int m_tagListStart = 0;
};
#endif // KEEPASSX_TAGMODEL_H
diff --git a/src/gui/tag/TagView.cpp b/src/gui/tag/TagView.cpp
new file mode 100644
index 000000000..82a977f3e
--- /dev/null
+++ b/src/gui/tag/TagView.cpp
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) 2022 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 "TagView.h"
+
+#include "TagModel.h"
+#include "core/Database.h"
+#include "core/Metadata.h"
+#include "gui/Icons.h"
+#include "gui/MessageBox.h"
+
+#include <QMenu>
+#include <QPainter>
+#include <QStyledItemDelegate>
+
+class TagItemDelegate : public QStyledItemDelegate
+{
+public:
+ explicit TagItemDelegate(QObject* parent)
+ : QStyledItemDelegate(parent){};
+
+ void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override
+ {
+ QStyledItemDelegate::paint(painter, option, index);
+ if (index.data(Qt::UserRole + 1).toBool()) {
+ QRect bounds = option.rect;
+ bounds.setY(bounds.bottom());
+ painter->fillRect(bounds, option.palette.mid());
+ }
+ }
+};
+
+TagView::TagView(QWidget* parent)
+ : QListView(parent)
+ , m_model(new TagModel(this))
+{
+ setModel(m_model);
+ setFrameStyle(QFrame::NoFrame);
+ setSelectionMode(QListView::ExtendedSelection);
+ setSelectionBehavior(QListView::SelectRows);
+ setContextMenuPolicy(Qt::CustomContextMenu);
+ setItemDelegate(new TagItemDelegate(this));
+
+ connect(this, &QListView::customContextMenuRequested, this, &TagView::contextMenuRequested);
+}
+
+void TagView::setDatabase(QSharedPointer<Database> db)
+{
+ m_db = db;
+ m_model->setDatabase(db);
+ setCurrentIndex(m_model->index(0));
+}
+
+void TagView::contextMenuRequested(const QPoint& pos)
+{
+ auto index = indexAt(pos);
+ if (!index.isValid()) {
+ return;
+ }
+
+ auto type = m_model->itemType(index);
+ if (type == TagModel::SAVED_SEARCH) {
+ // Allow deleting saved searches
+ QMenu menu;
+ auto action = menu.exec({new QAction(icons()->icon("trash"), tr("Remove Search"))}, mapToGlobal(pos));
+ if (action) {
+ m_db->metadata()->deleteSavedSearch(index.data(Qt::DisplayRole).toString());
+ }
+ } else if (type == TagModel::TAG) {
+ // Allow removing tags from all entries in a database
+ QMenu menu;
+ auto action = menu.exec({new QAction(icons()->icon("trash"), tr("Remove Tag"))}, mapToGlobal(pos));
+ if (action) {
+ auto tag = index.data(Qt::DisplayRole).toString();
+ auto ans = MessageBox::question(this,
+ tr("Confirm Remove Tag"),
+ tr("Remove tag \"%1\" from all entries in this database?").arg(tag),
+ MessageBox::Remove | MessageBox::Cancel);
+ if (ans == MessageBox::Remove) {
+ m_db->removeTag(tag);
+ }
+ }
+ }
+}
diff --git a/src/gui/tag/TagView.h b/src/gui/tag/TagView.h
new file mode 100644
index 000000000..9a135aca3
--- /dev/null
+++ b/src/gui/tag/TagView.h
@@ -0,0 +1,47 @@
+/*
+ * Copyright (C) 2022 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 KEEPASSXC_TAGVIEW_H
+#define KEEPASSXC_TAGVIEW_H
+
+#include <QListView>
+#include <QPointer>
+#include <QSharedPointer>
+
+class Database;
+class QAbstractListModel;
+class TagModel;
+
+class TagView : public QListView
+{
+ Q_OBJECT
+
+public:
+ explicit TagView(QWidget* parent = nullptr);
+ void setDatabase(QSharedPointer<Database> db);
+
+signals:
+
+private slots:
+ void contextMenuRequested(const QPoint& pos);
+
+private:
+ QSharedPointer<Database> m_db;
+ QPointer<TagModel> m_model;
+};
+
+#endif // KEEPASSX_ENTRYVIEW_H
diff --git a/src/gui/tag/TagsEdit.cpp b/src/gui/tag/TagsEdit.cpp
index ee668731a..52fc4853e 100644
--- a/src/gui/tag/TagsEdit.cpp
+++ b/src/gui/tag/TagsEdit.cpp
@@ -401,6 +401,7 @@ struct TagsEdit::Impl
// and ensures Invariant-1.
void editNewTag(int i)
{
+ currentText() = currentText().trimmed();
tags.insert(std::next(std::begin(tags), static_cast<std::ptrdiff_t>(i)), Tag());
if (editing_index >= i) {
++editing_index;
@@ -646,6 +647,12 @@ void TagsEdit::focusOutEvent(QFocusEvent*)
viewport()->update();
}
+void TagsEdit::hideEvent(QHideEvent* event)
+{
+ Q_UNUSED(event)
+ impl->completer->popup()->hide();
+}
+
void TagsEdit::paintEvent(QPaintEvent*)
{
QPainter p(viewport());
diff --git a/src/gui/tag/TagsEdit.h b/src/gui/tag/TagsEdit.h
index 6c2a974cb..44297fb34 100644
--- a/src/gui/tag/TagsEdit.h
+++ b/src/gui/tag/TagsEdit.h
@@ -68,6 +68,7 @@ protected:
void focusOutEvent(QFocusEvent* event) override;
void keyPressEvent(QKeyEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
+ void hideEvent(QHideEvent* event) override;
private:
bool isAcceptableInput(QKeyEvent const* event) const;
diff --git a/tests/TestEntrySearcher.cpp b/tests/TestEntrySearcher.cpp
index c85c57e2b..e2a91d212 100644
--- a/tests/TestEntrySearcher.cpp
+++ b/tests/TestEntrySearcher.cpp
@@ -205,7 +205,7 @@ void TestEntrySearcher::testSearchTermParser()
QCOMPARE(terms[0].exclude, true);
QCOMPARE(terms[1].field, EntrySearcher::Field::Undefined);
- QCOMPARE(terms[1].word, QString("quoted \\\"string\\\""));
+ QCOMPARE(terms[1].word, QString("quoted \"string\""));
QCOMPARE(terms[1].exclude, false);
QCOMPARE(terms[2].field, EntrySearcher::Field::Username);