diff --git a/COPYING b/COPYING index 35cfc1b1ee..4325e067b9 100644 --- a/COPYING +++ b/COPYING @@ -144,10 +144,24 @@ Files: share/icons/badges/2_Expired.svg Copyright: 2022 KeePassXC Team License: MIT -Files: share/icons/application/scalable/actions/chevron-double-down.svg +Files: share/icons/application/scalable/actions/application-exit.svg + share/icons/application/scalable/actions/attributes-copy.svg + share/icons/application/scalable/actions/auto-type.svg + share/icons/application/scalable/actions/bugreport.svg + share/icons/application/scalable/actions/chevron-double-down.svg share/icons/application/scalable/actions/chevron-double-right.svg + share/icons/application/scalable/actions/totp.svg + share/icons/application/scalable/actions/totp-copy.svg + share/icons/application/scalable/actions/totp-edit.svg + share/icons/application/scalable/actions/clipboard-text.svg + share/icons/application/scalable/actions/configure.svg + share/icons/application/scalable/actions/database-change-key.svg share/icons/application/scalable/actions/database-lock.svg share/icons/application/scalable/actions/database-lock-all.svg + share/icons/application/scalable/actions/database-merge.svg + share/icons/application/scalable/actions/database-search.svg + share/icons/application/scalable/actions/dialog-close.svg + share/icons/application/scalable/actions/dialog-ok.svg share/icons/application/scalable/actions/document-close.svg share/icons/application/scalable/actions/document-edit.svg share/icons/application/scalable/actions/document-export.svg @@ -159,43 +173,56 @@ Files: share/icons/application/scalable/actions/chevron-double-down.svg share/icons/application/scalable/actions/document-save.svg share/icons/application/scalable/actions/document-save-as.svg share/icons/application/scalable/actions/document-save-copy.svg + share/icons/application/scalable/actions/donate.svg share/icons/application/scalable/actions/edit-clear-locationbar-ltr.svg share/icons/application/scalable/actions/edit-clear-locationbar-rtl.svg share/icons/application/scalable/actions/entry-clone.svg share/icons/application/scalable/actions/entry-delete.svg + share/icons/application/scalable/actions/entry-restore.svg share/icons/application/scalable/actions/entry-edit.svg share/icons/application/scalable/actions/entry-new.svg share/icons/application/scalable/actions/favicon-download.svg share/icons/application/scalable/actions/fingerprint.svg - share/icons/application/scalable/actions/group-clone.svg + share/icons/application/scalable/actions/getting-started.svg share/icons/application/scalable/actions/group-delete.svg share/icons/application/scalable/actions/group-edit.svg + share/icons/application/scalable/actions/group-clone.svg share/icons/application/scalable/actions/group-empty-trash.svg share/icons/application/scalable/actions/group-new.svg share/icons/application/scalable/actions/hammer-wrench.svg share/icons/application/scalable/actions/health.svg share/icons/application/scalable/actions/help-about.svg share/icons/application/scalable/actions/lock-question.svg + share/icons/application/scalable/actions/keyboard-shortcuts.svg share/icons/application/scalable/actions/message-close.svg share/icons/application/scalable/actions/move-down.svg share/icons/application/scalable/actions/move-up.svg + share/icons/application/scalable/actions/object-locked.svg + share/icons/application/scalable/actions/object-unlocked.svg share/icons/application/scalable/actions/paperclip.svg share/icons/application/scalable/actions/password-copy.svg share/icons/application/scalable/actions/password-generator.svg share/icons/application/scalable/actions/password-show-off.svg share/icons/application/scalable/actions/password-show-on.svg + share/icons/application/scalable/actions/qrcode.svg share/icons/application/scalable/actions/refresh.svg share/icons/application/scalable/actions/reports.svg share/icons/application/scalable/actions/reports-exclude.svg + share/icons/application/scalable/actions/sort-alphabetical-ascending.svg + share/icons/application/scalable/actions/sort-alphabetical-descending.svg share/icons/application/scalable/actions/statistics.svg share/icons/application/scalable/actions/system-help.svg share/icons/application/scalable/actions/system-search.svg + share/icons/application/scalable/actions/system-software-update.svg share/icons/application/scalable/actions/tag.svg + share/icons/application/scalable/actions/tag-multiple.svg share/icons/application/scalable/actions/tag-search.svg share/icons/application/scalable/actions/trash.svg share/icons/application/scalable/actions/url-copy.svg + share/icons/application/scalable/actions/user-guide.svg share/icons/application/scalable/actions/username-copy.svg share/icons/application/scalable/actions/view-history.svg + share/icons/application/scalable/actions/web.svg share/icons/application/scalable/apps/internet-web-browser.svg share/icons/application/scalable/apps/keepassxc.svg share/icons/application/scalable/apps/keepassxc-dark.svg diff --git a/share/icons/application/scalable/actions/attributes-copy.svg b/share/icons/application/scalable/actions/attributes-copy.svg new file mode 100644 index 0000000000..b4e4725b77 --- /dev/null +++ b/share/icons/application/scalable/actions/attributes-copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/actions/database-search.svg b/share/icons/application/scalable/actions/database-search.svg new file mode 100644 index 0000000000..a1a30a6758 --- /dev/null +++ b/share/icons/application/scalable/actions/database-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/actions/qrcode.svg b/share/icons/application/scalable/actions/qrcode.svg new file mode 100644 index 0000000000..7778d10542 --- /dev/null +++ b/share/icons/application/scalable/actions/qrcode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/actions/tag-multiple.svg b/share/icons/application/scalable/actions/tag-multiple.svg new file mode 100644 index 0000000000..925e47ed1e --- /dev/null +++ b/share/icons/application/scalable/actions/tag-multiple.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/actions/totp-copy.svg b/share/icons/application/scalable/actions/totp-copy.svg new file mode 100644 index 0000000000..1f134017cd --- /dev/null +++ b/share/icons/application/scalable/actions/totp-copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/actions/totp-edit.svg b/share/icons/application/scalable/actions/totp-edit.svg new file mode 100644 index 0000000000..b3814fc4dc --- /dev/null +++ b/share/icons/application/scalable/actions/totp-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/application/scalable/actions/chronometer.svg b/share/icons/application/scalable/actions/totp.svg similarity index 100% rename from share/icons/application/scalable/actions/chronometer.svg rename to share/icons/application/scalable/actions/totp.svg diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index 209a1d35cc..8c8c78211f 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -6,17 +6,21 @@ application/256x256/apps/keepassxc.png application/scalable/actions/application-exit.svg + application/scalable/actions/attributes-copy.svg application/scalable/actions/auto-type.svg application/scalable/actions/bugreport.svg application/scalable/actions/chevron-double-down.svg application/scalable/actions/chevron-double-right.svg - application/scalable/actions/chronometer.svg + application/scalable/actions/totp.svg + application/scalable/actions/totp-copy.svg + application/scalable/actions/totp-edit.svg application/scalable/actions/clipboard-text.svg application/scalable/actions/configure.svg application/scalable/actions/database-change-key.svg application/scalable/actions/database-lock.svg application/scalable/actions/database-lock-all.svg application/scalable/actions/database-merge.svg + application/scalable/actions/database-search.svg application/scalable/actions/dialog-close.svg application/scalable/actions/dialog-ok.svg application/scalable/actions/document-close.svg @@ -62,6 +66,7 @@ application/scalable/actions/password-generator.svg application/scalable/actions/password-show-off.svg application/scalable/actions/password-show-on.svg + application/scalable/actions/qrcode.svg application/scalable/actions/refresh.svg application/scalable/actions/reports.svg application/scalable/actions/reports-exclude.svg @@ -72,6 +77,7 @@ application/scalable/actions/system-search.svg application/scalable/actions/system-software-update.svg application/scalable/actions/tag.svg + application/scalable/actions/tag-multiple.svg application/scalable/actions/tag-search.svg application/scalable/actions/trash.svg application/scalable/actions/url-copy.svg diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index edba1690f7..e36975737c 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -2249,10 +2249,6 @@ This is definitely a bug, please report it to the developers. DatabaseWidget - - Database Tags - - Searching… @@ -2417,6 +2413,22 @@ Disable safe saves and try again? + + Searches and Tags + + + + Enter a unique name or overwrite an existing search from the list: + + + + Save + + + + Save Search + + EditEntryWidget @@ -5403,6 +5415,21 @@ We recommend you use the AppImage available on our downloads page. You must restart the application to apply this setting. Would you like to restart now? + + Tags + + + + No Tags + + + + %1 Entry(s) + + + + + ManageDatabase @@ -8372,6 +8399,10 @@ Kernel: %3 %4 Limit search to selected group + + Save Search + + SettingsClientModel @@ -8585,15 +8616,38 @@ Kernel: %3 %4 TagModel - All + Expired - Expired + Weak Passwords - Weak Passwords + All Entries + + + + Clear Search + + + + + TagView + + Remove Search + + + + Remove Tag + + + + Confirm Remove Tag + + + + Remove tag "%1" from all entries in this database? diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4cf802f303..b0bd3e0abd 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/autotype/AutoTypeSelectDialog.cpp b/src/autotype/AutoTypeSelectDialog.cpp index b9d20fe8df..38c1ac58c1 100644 --- a/src/autotype/AutoTypeSelectDialog.cpp +++ b/src/autotype/AutoTypeSelectDialog.cpp @@ -294,7 +294,7 @@ void AutoTypeSelectDialog::buildActionMenu() auto typeTotpAction = new QAction(icons()->icon("auto-type"), tr("Type {TOTP}"), this); auto copyUsernameAction = new QAction(icons()->icon("username-copy"), tr("Copy Username"), this); auto copyPasswordAction = new QAction(icons()->icon("password-copy"), tr("Copy Password"), this); - auto copyTotpAction = new QAction(icons()->icon("chronometer"), tr("Copy TOTP"), this); + auto copyTotpAction = new QAction(icons()->icon("totp"), tr("Copy TOTP"), this); m_actionMenu->addAction(typeUsernameAction); m_actionMenu->addAction(typePasswordAction); m_actionMenu->addAction(typeTotpAction); diff --git a/src/core/Database.cpp b/src/core/Database.cpp index 7d92265a27..7a10f0483e 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 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 bad0b256a1..e1bc2ec96a 100644 --- a/src/core/Database.h +++ b/src/core/Database.h @@ -129,6 +129,7 @@ class Database : public ModifiableObject const QStringList& commonUsernames() const; const QStringList& tagList() const; + void removeTag(const QString& tag); QSharedPointer key() const; bool setKey(const QSharedPointer& key, diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 66b60252c1..f507bed41b 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::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 79390ff7ab..7fc69c8fb9 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 @@ class Entry : public ModifiableObject void setPreviousParentGroup(const Group* group); void setPreviousParentGroupUuid(const QUuid& uuid); + void addTag(const QString& tag); + void removeTag(const QString& tag); + QList historyItems(); const QList& historyItems() const; void addHistoryItem(Entry* entry); diff --git a/src/core/EntrySearcher.cpp b/src/core/EntrySearcher.cpp index e52033a04a..3292ca112d 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: + {QStringLiteral("username"), Field::Username}, // username before url to capture u: {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 80c86600c8..9376d10de1 100644 --- a/src/core/EntrySearcher.h +++ b/src/core/EntrySearcher.h @@ -71,7 +71,6 @@ class EntrySearcher bool m_caseSensitive; bool m_skipProtected; - QRegularExpression m_termParser; QList m_searchTerms; friend class TestEntrySearcher; diff --git a/src/core/Metadata.cpp b/src/core/Metadata.cpp index d88998057e..52a615e288 100644 --- a/src/core/Metadata.cpp +++ b/src/core/Metadata.cpp @@ -24,6 +24,7 @@ #include #include +#include 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 61c9c1e6e9..ccefdb1c82 100644 --- a/src/core/Metadata.h +++ b/src/core/Metadata.h @@ -23,6 +23,7 @@ #include #include #include +#include #include "core/CustomData.h" #include "core/Global.h" @@ -150,6 +151,9 @@ class Metadata : public ModifiableObject 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 2ac0779c63..08d8d4dec1 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -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 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 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); @@ -204,15 +200,7 @@ DatabaseWidget::DatabaseWidget(QSharedPointer db, QWidget* parent) connect(m_previewView, SIGNAL(entryUrlActivated(Entry*)), SLOT(openUrlForEntry(Entry*))); connect(m_entryView, SIGNAL(viewStateChanged()), SIGNAL(entryViewStateChanged())); 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 +419,7 @@ void DatabaseWidget::replaceDatabase(QSharedPointer 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 @@ -571,11 +558,7 @@ void DatabaseWidget::deleteEntries(QList selectedEntries, bool confirm) void DatabaseWidget::setFocus(Qt::FocusReason reason) { - if (reason == Qt::BacktabFocusReason) { - m_previewView->setFocus(); - } else { - m_groupView->setFocus(); - } + focusNextPrevChild(reason == Qt::TabFocusReason); } void DatabaseWidget::focusOnEntries(bool editIfFocused) @@ -690,11 +673,23 @@ void DatabaseWidget::copyAttribute(QAction* action) } } -void DatabaseWidget::filterByTag(const QModelIndex& index) +void DatabaseWidget::filterByTag() +{ + 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) { - m_tagView->selectionModel()->setCurrentIndex(index, QItemSelectionModel::Select); - const auto model = static_cast(m_tagView->model()); - emit requestSearch(model->data(index, Qt::UserRole).toString()); + auto tag = action->text(); + auto state = action->isChecked(); + for (auto entry : m_entryView->selectedEntries()) { + state ? entry->addTag(tag) : entry->removeTag(tag); + } } void DatabaseWidget::showTotpKeyQrCode() @@ -1045,12 +1040,6 @@ void DatabaseWidget::switchToMainView(bool previousDialogAccepted) // Workaround: ensure entries are focused so search doesn't reset m_entryView->setFocus(); } - - if (sender() == m_entryView || sender() == m_editEntryWidget) { - onEntryChanged(m_entryView->currentEntry()); - } else if (sender() == m_groupView || sender() == m_editGroupWidget) { - onGroupChanged(); - } } void DatabaseWidget::switchToHistoryView(Entry* entry) @@ -1128,22 +1117,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 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(); - m_searchingLabel->setText( - expirationOffset == 0 - ? tr("Expired entries") - : tr("Entries expiring within %1 day(s)", "", expirationOffset).arg(expirationOffset)); - m_searchingLabel->setVisible(true); + if (expirationOffset <= 0) { + m_nextSearchLabelText = tr("Expired entries"); + } else { + m_nextSearchLabelText = + tr("Entries expiring within %1 day(s)", "", expirationOffset).arg(expirationOffset); } + requestSearch(QString("is:expired-%1").arg(expirationOffset)); } m_groupBeforeLock = QUuid(); @@ -1435,7 +1415,10 @@ void DatabaseWidget::search(const QString& searchtext) m_lastSearchText = searchtext; // Display a label detailing our search results - if (!searchResult.isEmpty()) { + if (!m_nextSearchLabelText.isEmpty()) { + m_searchingLabel->setText(m_nextSearchLabelText); + m_nextSearchLabelText.clear(); + } else if (!searchResult.isEmpty()) { m_searchingLabel->setText(tr("Search Results (%1)").arg(searchResult.size())); } else { m_searchingLabel->setText(tr("No Results")); @@ -1449,6 +1432,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); @@ -1484,6 +1501,8 @@ void DatabaseWidget::onGroupChanged() m_shareLabel->setVisible(false); } #endif + + emit groupChanged(); } void DatabaseWidget::onDatabaseModified() @@ -1520,6 +1539,7 @@ void DatabaseWidget::endSearch() m_searchingLabel->setText(tr("Searching…")); m_lastSearchText.clear(); + m_nextSearchLabelText.clear(); // Tell the search widget to clear emit clearSearch(); @@ -1539,6 +1559,8 @@ void DatabaseWidget::onEntryChanged(Entry* entry) { if (entry) { m_previewView->setEntry(entry); + } else { + m_previewView->setGroup(groupView()->currentGroup()); } emit entrySelectionChanged(); @@ -1586,31 +1608,32 @@ void DatabaseWidget::showEvent(QShowEvent* event) bool DatabaseWidget::focusNextPrevChild(bool next) { // [parent] <-> GroupView <-> TagView <-> EntryView <-> EntryPreview <-> [parent] - if (next) { - if (m_groupView->hasFocus()) { - m_tagView->setFocus(); - return true; - } else if (m_tagView->hasFocus()) { - m_entryView->setFocus(); - return true; - } else if (m_entryView->hasFocus()) { - m_previewView->setFocus(); - return true; - } + QList sequence = {m_groupView, m_tagView, m_entryView, m_previewView}; + auto widget = qApp->focusWidget(); + + int idx; + do { + idx = sequence.indexOf(widget); + widget = widget->parentWidget(); + } while (idx == -1 && widget); + + if (idx == -1) { + idx = next ? 0 : sequence.size() - 1; } else { - if (m_previewView->hasFocus()) { - m_entryView->setFocus(); - return true; - } else if (m_entryView->hasFocus()) { - m_tagView->setFocus(); - return true; - } else if (m_tagView->hasFocus()) { - m_groupView->setFocus(); - return true; + idx = next ? idx + 1 : idx - 1; + } + + // Find the next visible element in the sequence and set the focus + while (idx >= 0 && idx < sequence.size()) { + widget = sequence[idx]; + if (widget->isVisible() && widget->height() > 0 && widget->width() > 0) { + widget->setFocus(); + return widget; } + idx = next ? idx + 1 : idx - 1; } - // Defer to the parent widget to make a decision + // Ran out of options, defer to the parent widget return QStackedWidget::focusNextPrevChild(next); } diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index d77a38dd7b..57108dfbf6 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 slots: QPointer m_keepass1OpenWidget; QPointer m_opVaultOpenWidget; QPointer m_groupView; - QPointer m_tagView; + QPointer m_tagView; QPointer m_entryView; QScopedPointer m_newGroup; @@ -298,6 +302,7 @@ private slots: // Search state QScopedPointer m_entrySearcher; QString m_lastSearchText; + QString m_nextSearchLabelText; bool m_searchLimitGroup; // Autoreload diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp index 5fb3d34065..304fc733f7 100644 --- a/src/gui/EntryPreviewWidget.cpp +++ b/src/gui/EntryPreviewWidget.cpp @@ -45,7 +45,7 @@ EntryPreviewWidget::EntryPreviewWidget(QWidget* parent) m_ui->setupUi(this); // Entry - m_ui->entryTotpButton->setIcon(icons()->icon("chronometer")); + m_ui->entryTotpButton->setIcon(icons()->icon("totp")); m_ui->entryCloseButton->setIcon(icons()->icon("dialog-close")); m_ui->togglePasswordButton->setIcon(icons()->onOffIcon("password-show", true)); m_ui->toggleEntryNotesButton->setIcon(icons()->onOffIcon("password-show", true)); @@ -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 8a5b0c09f5..a6a8d0ca4f 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 cab6a5a4c6..163ae70115 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -43,6 +43,7 @@ #include "gui/Icons.h" #include "gui/MessageBox.h" #include "gui/SearchWidget.h" +#include "gui/entry/EntryView.h" #include "gui/osutils/OSUtils.h" #ifdef WITH_XC_UPDATECHECK @@ -129,6 +130,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 +242,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(config()->get(Config::GlobalAutoTypeKey).toInt()); Qt::KeyboardModifiers globalAutoTypeModifiers = static_cast(config()->get(Config::GlobalAutoTypeModifiers).toInt()); @@ -395,6 +402,13 @@ MainWindow::MainWindow() m_ui->actionEntryCopyUsername->setIcon(icons()->icon("username-copy")); m_ui->actionEntryCopyPassword->setIcon(icons()->icon("password-copy")); m_ui->actionEntryCopyURL->setIcon(icons()->icon("url-copy")); + m_ui->menuEntryCopyAttribute->setIcon(icons()->icon("attributes-copy")); + m_ui->menuEntryTotp->setIcon(icons()->icon("totp")); + m_ui->actionEntryTotp->setIcon(icons()->icon("totp")); + m_ui->actionEntryCopyTotp->setIcon(icons()->icon("totp-copy")); + m_ui->actionEntryTotpQRCode->setIcon(icons()->icon("qrcode")); + m_ui->actionEntrySetupTotp->setIcon(icons()->icon("totp-edit")); + m_ui->menuTags->setIcon(icons()->icon("tag-multiple")); m_ui->actionEntryDownloadIcon->setIcon(icons()->icon("favicon-download")); m_ui->actionGroupSortAsc->setIcon(icons()->icon("sort-alphabetical-ascending")); m_ui->actionGroupSortDesc->setIcon(icons()->icon("sort-alphabetical-descending")); @@ -425,6 +439,11 @@ MainWindow::MainWindow() m_actionMultiplexer.connect(SIGNAL(entrySelectionChanged()), this, SLOT(setMenuActionState())); m_actionMultiplexer.connect(SIGNAL(groupContextMenuRequested(QPoint)), this, SLOT(showGroupContextMenu(QPoint))); m_actionMultiplexer.connect(SIGNAL(entryContextMenuRequested(QPoint)), this, SLOT(showEntryContextMenu(QPoint))); + m_actionMultiplexer.connect(SIGNAL(groupChanged()), this, SLOT(updateEntryCountLabel())); + m_actionMultiplexer.connect(SIGNAL(databaseUnlocked()), this, SLOT(updateEntryCountLabel())); + m_actionMultiplexer.connect(SIGNAL(databaseModified()), this, SLOT(updateEntryCountLabel())); + m_actionMultiplexer.connect(SIGNAL(searchModeActivated()), this, SLOT(updateEntryCountLabel())); + m_actionMultiplexer.connect(SIGNAL(listModeActivated()), this, SLOT(updateEntryCountLabel())); // Notify search when the active database changes or gets locked connect(m_ui->tabWidget, @@ -647,6 +666,7 @@ MainWindow::MainWindow() connect(qApp, SIGNAL(openFile(QString)), this, SLOT(openDatabase(QString))); connect(qApp, SIGNAL(quitSignalReceived()), this, SLOT(appExit()), Qt::DirectConnection); + // Setup the status bar statusBar()->setFixedHeight(24); m_progressBarLabel = new QLabel(statusBar()); m_progressBarLabel->setVisible(false); @@ -659,6 +679,8 @@ MainWindow::MainWindow() m_progressBar->setMaximum(100); statusBar()->addPermanentWidget(m_progressBar); connect(clipboard(), SIGNAL(updateCountdown(int, QString)), this, SLOT(updateProgressBar(int, QString))); + m_statusBarLabel = new QLabel(statusBar()); + statusBar()->addPermanentWidget(m_statusBarLabel); restoreConfigState(); } @@ -791,6 +813,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 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 +924,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( @@ -1507,6 +1562,17 @@ void MainWindow::updateProgressBar(int percentage, QString message) } } +void MainWindow::updateEntryCountLabel() +{ + auto dbWidget = m_ui->tabWidget->currentDatabaseWidget(); + if (dbWidget && dbWidget->currentMode() == DatabaseWidget::Mode::ViewMode) { + int numEntries = dbWidget->entryView()->model()->rowCount(); + m_statusBarLabel->setText(tr("%1 Entry(s)", "", numEntries).arg(numEntries)); + } else { + m_statusBarLabel->setText(""); + } +} + void MainWindow::obtainContextFocusLock() { m_contextMenuFocusLock = true; diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 48061c757f..ed9c506a31 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(); @@ -146,6 +147,7 @@ private slots: void agentEnabled(bool enabled); void updateTrayIcon(); void updateProgressBar(int percentage, QString message); + void updateEntryCountLabel(); void focusSearchWidget(); private: @@ -172,6 +174,7 @@ private slots: QPointer m_entryNewContextMenu; QPointer m_lastDatabasesActions; QPointer m_copyAdditionalAttributeActions; + QPointer m_setTagsMenuActions; QPointer m_inactivityTimer; QPointer m_touchIDinactivityTimer; int m_countDefaultAttributes; @@ -180,6 +183,7 @@ private slots: QPointer m_searchWidget; QPointer m_progressBar; QPointer m_progressBarLabel; + QPointer m_statusBarLabel; Q_DISABLE_COPY(MainWindow) diff --git a/src/gui/MainWindow.ui b/src/gui/MainWindow.ui index b92b007426..86f400f3cb 100644 --- a/src/gui/MainWindow.ui +++ b/src/gui/MainWindow.ui @@ -316,6 +316,11 @@ + + + Tags + + @@ -328,6 +333,7 @@ + diff --git a/src/gui/SearchWidget.cpp b/src/gui/SearchWidget.cpp index 989cd9a4b0..8a04ec14f2 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()) { 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 820e9fea8b..55edad5833 100644 --- a/src/gui/SearchWidget.h +++ b/src/gui/SearchWidget.h @@ -61,6 +61,7 @@ class SearchWidget : public QWidget 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 c924b40768..ab4ef13029 100644 --- a/src/gui/SearchWidget.ui +++ b/src/gui/SearchWidget.ui @@ -56,6 +56,11 @@ Search Help + + + Save Search + + searchEdit diff --git a/src/gui/entry/EditEntryWidgetMain.ui b/src/gui/entry/EditEntryWidgetMain.ui index 6b0f951789..894f561159 100644 --- a/src/gui/entry/EditEntryWidgetMain.ui +++ b/src/gui/entry/EditEntryWidgetMain.ui @@ -320,8 +320,8 @@ usernameComboBox passwordEdit urlEdit - tagsList fetchFaviconButton + tagsList expireCheck expireDatePicker expirePresets diff --git a/src/gui/entry/EntryModel.cpp b/src/gui/entry/EntryModel.cpp index 8d2bc30cd3..2a125a8818 100644 --- a/src/gui/entry/EntryModel.cpp +++ b/src/gui/entry/EntryModel.cpp @@ -291,7 +291,7 @@ QVariant EntryModel::data(const QModelIndex& index, int role) const break; case Totp: if (entry->hasTotp()) { - return icons()->icon("chronometer"); + return icons()->icon("totp"); } break; case PasswordStrength: @@ -388,7 +388,7 @@ QVariant EntryModel::headerData(int section, Qt::Orientation orientation, int ro case Paperclip: return icons()->icon("paperclip"); case Totp: - return icons()->icon("chronometer"); + return icons()->icon("totp"); case PasswordStrength: return icons()->icon("lock-question"); } diff --git a/src/gui/entry/EntryView.cpp b/src/gui/entry/EntryView.cpp index 09362e3919..67a9698b7f 100644 --- a/src/gui/entry/EntryView.cpp +++ b/src/gui/entry/EntryView.cpp @@ -263,6 +263,15 @@ Entry* EntryView::currentEntry() } } +QList EntryView::selectedEntries() +{ + QList 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 90f37abfcb..c7136383ad 100644 --- a/src/gui/entry/EntryView.h +++ b/src/gui/entry/EntryView.h @@ -38,6 +38,7 @@ class EntryView : public QTreeView void setModel(QAbstractItemModel* model) override; Entry* currentEntry(); void setCurrentEntry(Entry* entry); + QList 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 023cb34984..99f2532707 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 db, QObject* parent) +#include +#include + +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 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 020f621f03..8eee0101b9 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 db, QObject* parent = nullptr); + explicit TagModel(QObject* parent = nullptr); ~TagModel() override; void setDatabase(QSharedPointer 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 m_db; - QStringList m_tagList; + QList> m_defaultSearches; + QList> 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 0000000000..82a977f3ee --- /dev/null +++ b/src/gui/tag/TagView.cpp @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2022 KeePassXC Team + * + * 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 . + */ + +#include "TagView.h" + +#include "TagModel.h" +#include "core/Database.h" +#include "core/Metadata.h" +#include "gui/Icons.h" +#include "gui/MessageBox.h" + +#include +#include +#include + +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 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 0000000000..9a135aca34 --- /dev/null +++ b/src/gui/tag/TagView.h @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2022 KeePassXC Team + * + * 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 . + */ + +#ifndef KEEPASSXC_TAGVIEW_H +#define KEEPASSXC_TAGVIEW_H + +#include +#include +#include + +class Database; +class QAbstractListModel; +class TagModel; + +class TagView : public QListView +{ + Q_OBJECT + +public: + explicit TagView(QWidget* parent = nullptr); + void setDatabase(QSharedPointer db); + +signals: + +private slots: + void contextMenuRequested(const QPoint& pos); + +private: + QSharedPointer m_db; + QPointer m_model; +}; + +#endif // KEEPASSX_ENTRYVIEW_H diff --git a/src/gui/tag/TagsEdit.cpp b/src/gui/tag/TagsEdit.cpp index ee668731a5..52fc4853eb 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(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 6c2a974cb4..44297fb343 100644 --- a/src/gui/tag/TagsEdit.h +++ b/src/gui/tag/TagsEdit.h @@ -68,6 +68,7 @@ class TagsEdit : public QAbstractScrollArea 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 cc19a0c251..4729fb446a 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); diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index c00e3a10b3..c1b339f33e 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -111,6 +111,8 @@ void TestGui::init() config()->set(Config::Security_QuickUnlock, false); // Disable atomic saves to prevent transient errors on some platforms config()->set(Config::UseAtomicSaves, false); + // Disable showing expired entries on unlock + config()->set(Config::GUI_ShowExpiredEntriesOnDatabaseUnlock, false); // Copy the test database file to the temporary file auto origFilePath = QDir(KEEPASSX_TEST_DATA_DIR).absoluteFilePath("NewDatabase.kdbx");