diff --git a/share/icons/application/16x16/actions/system-help.png b/share/icons/application/16x16/actions/system-help.png new file mode 100644 index 000000000..75ebaf7f5 Binary files /dev/null and b/share/icons/application/16x16/actions/system-help.png differ diff --git a/share/icons/application/22x22/actions/system-help.png b/share/icons/application/22x22/actions/system-help.png new file mode 100644 index 000000000..86b64075f Binary files /dev/null and b/share/icons/application/22x22/actions/system-help.png differ diff --git a/share/icons/application/32x32/actions/system-help.png b/share/icons/application/32x32/actions/system-help.png new file mode 100644 index 000000000..8a9eb1a82 Binary files /dev/null and b/share/icons/application/32x32/actions/system-help.png differ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5cd974a98..f68afc8f6 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -126,7 +126,6 @@ set(keepassx_SOURCES gui/UnlockDatabaseWidget.cpp gui/UnlockDatabaseDialog.cpp gui/WelcomeWidget.cpp - gui/widgets/ElidedLabel.cpp gui/csvImport/CsvImportWidget.cpp gui/csvImport/CsvImportWizard.cpp gui/csvImport/CsvParserModel.cpp @@ -154,6 +153,8 @@ set(keepassx_SOURCES gui/dbsettings/DatabaseSettingsWidgetEncryption.cpp gui/dbsettings/DatabaseSettingsWidgetMasterKey.cpp gui/settings/SettingsWidget.cpp + gui/widgets/ElidedLabel.cpp + gui/widgets/PopupHelpWidget.cpp gui/wizard/NewDatabaseWizard.cpp gui/wizard/NewDatabaseWizardPage.cpp gui/wizard/NewDatabaseWizardPageMetaData.cpp diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index a69508026..95b9008e6 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -88,7 +88,7 @@ bool BrowserService::openDatabase(bool triggerUnlock) } if (triggerUnlock) { - KEEPASSXC_MAIN_WINDOW->bringToFront(); + getMainWindow()->bringToFront(); m_bringToFrontRequested = true; } @@ -390,7 +390,7 @@ QList BrowserService::searchEntries(Database* db, const QString& hostnam return entries; } - for (Entry* entry : EntrySearcher().search(baseDomain(hostname), rootGroup, Qt::CaseInsensitive)) { + for (Entry* entry : EntrySearcher().search(baseDomain(hostname), rootGroup)) { QString entryUrl = entry->url(); QUrl entryQUrl(entryUrl); QString entryScheme = entryQUrl.scheme(); @@ -901,7 +901,7 @@ void BrowserService::databaseUnlocked(DatabaseWidget* dbWidget) { if (dbWidget) { if (m_bringToFrontRequested) { - KEEPASSXC_MAIN_WINDOW->lower(); + getMainWindow()->lower(); m_bringToFrontRequested = false; } emit databaseUnlocked(); diff --git a/src/core/EntrySearcher.cpp b/src/core/EntrySearcher.cpp index 3413f1cd0..82e6eaa0c 100644 --- a/src/core/EntrySearcher.cpp +++ b/src/core/EntrySearcher.cpp @@ -19,65 +19,92 @@ #include "EntrySearcher.h" #include "core/Group.h" +#include "core/Tools.h" -QList EntrySearcher::search(const QString& searchTerm, const Group* group, Qt::CaseSensitivity caseSensitivity) +EntrySearcher::EntrySearcher(bool caseSensitive) + : m_caseSensitive(caseSensitive) + , m_termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re") + // Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string { - if (!group->resolveSearchingEnabled()) { - return QList(); - } - - return searchEntries(searchTerm, group, caseSensitivity); } -QList -EntrySearcher::searchEntries(const QString& searchTerm, const Group* group, Qt::CaseSensitivity caseSensitivity) +QList EntrySearcher::search(const QString& searchString, const Group* baseGroup, bool forceSearch) { - QList searchResult; + Q_ASSERT(baseGroup); - const QList& entryList = group->entries(); - for (Entry* entry : entryList) { - searchResult.append(matchEntry(searchTerm, entry, caseSensitivity)); - } - - const QList& children = group->children(); - for (Group* childGroup : children) { - if (childGroup->searchingEnabled() != Group::Disable) { - if (matchGroup(searchTerm, childGroup, caseSensitivity)) { - searchResult.append(childGroup->entriesRecursive()); - } else { - searchResult.append(searchEntries(searchTerm, childGroup, caseSensitivity)); - } + QList results; + for (const auto group : baseGroup->groupsRecursive(true)) { + if (forceSearch || group->resolveSearchingEnabled()) { + results.append(searchEntries(searchString, group->entries())); } } - return searchResult; + return results; } -QList EntrySearcher::matchEntry(const QString& searchTerm, Entry* entry, Qt::CaseSensitivity caseSensitivity) +QList EntrySearcher::searchEntries(const QString& searchString, const QList& entries) { - const QStringList wordList = searchTerm.split(QRegExp("\\s"), QString::SkipEmptyParts); - for (const QString& word : wordList) { - if (!wordMatch(word, entry, caseSensitivity)) { - return QList(); - } + QList results; + for (Entry* entry : entries) { + if (searchEntryImpl(searchString, entry)) { + results.append(entry); + } } - - return QList() << entry; + return results; } -bool EntrySearcher::wordMatch(const QString& word, Entry* entry, Qt::CaseSensitivity caseSensitivity) +void EntrySearcher::setCaseSensitive(bool state) { - return entry->resolvePlaceholder(entry->title()).contains(word, caseSensitivity) - || entry->resolvePlaceholder(entry->username()).contains(word, caseSensitivity) - || entry->resolvePlaceholder(entry->url()).contains(word, caseSensitivity) - || entry->resolvePlaceholder(entry->notes()).contains(word, caseSensitivity); + m_caseSensitive = state; } -bool EntrySearcher::matchGroup(const QString& searchTerm, const Group* group, Qt::CaseSensitivity caseSensitivity) +bool EntrySearcher::isCaseSensitive() { - const QStringList wordList = searchTerm.split(QRegExp("\\s"), QString::SkipEmptyParts); - for (const QString& word : wordList) { - if (!wordMatch(word, group, caseSensitivity)) { + return m_caseSensitive; +} + +bool EntrySearcher::searchEntryImpl(const QString& searchString, Entry* entry) +{ + // Pre-load in case they are needed + auto attributes = QStringList(entry->attributes()->keys()); + auto attachments = QStringList(entry->attachments()->keys()); + + bool found; + auto searchTerms = parseSearchTerms(searchString); + + for (const auto& term : searchTerms) { + switch (term->field) { + case Field::Title: + found = term->regex.match(entry->resolvePlaceholder(entry->title())).hasMatch(); + break; + case Field::Username: + found = term->regex.match(entry->resolvePlaceholder(entry->username())).hasMatch(); + break; + case Field::Password: + found = term->regex.match(entry->resolvePlaceholder(entry->password())).hasMatch(); + break; + case Field::Url: + found = term->regex.match(entry->resolvePlaceholder(entry->url())).hasMatch(); + break; + case Field::Notes: + found = term->regex.match(entry->notes()).hasMatch(); + break; + case Field::Attribute: + found = !attributes.filter(term->regex).empty(); + break; + case Field::Attachment: + found = !attachments.filter(term->regex).empty(); + break; + default: + // Terms without a specific field try to match title, username, url, and notes + 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->notes()).hasMatch(); + } + + // Short circuit if we failed to match or we matched and are excluding this term + if ((!found && !term->exclude) || (found && term->exclude)) { return false; } } @@ -85,7 +112,61 @@ bool EntrySearcher::matchGroup(const QString& searchTerm, const Group* group, Qt return true; } -bool EntrySearcher::wordMatch(const QString& word, const Group* group, Qt::CaseSensitivity caseSensitivity) +QList > EntrySearcher::parseSearchTerms(const QString& searchString) { - return group->name().contains(word, caseSensitivity) || group->notes().contains(word, caseSensitivity); + auto terms = QList >(); + + auto results = m_termParser.globalMatch(searchString); + while (results.hasNext()) { + auto result = results.next(); + auto term = QSharedPointer::create(); + + // Quoted string group + term->word = result.captured(3); + + // If empty, use the unquoted string group + if (term->word.isEmpty()) { + term->word = result.captured(4); + } + + // If still empty, ignore this match + if (term->word.isEmpty()) { + continue; + } + + auto mods = result.captured(1); + + // Convert term to regex + term->regex = Tools::convertToRegex(term->word, !mods.contains("*"), mods.contains("+"), m_caseSensitive); + + // Exclude modifier + term->exclude = mods.contains("-") || mods.contains("!"); + + // Determine the field to search + QString field = result.captured(2); + if (!field.isEmpty()) { + auto cs = Qt::CaseInsensitive; + if (field.compare("title", cs) == 0) { + term->field = Field::Title; + } else if (field.startsWith("user", cs)) { + term->field = Field::Username; + } else if (field.startsWith("pass", cs)) { + term->field = Field::Password; + } else if (field.compare("url", cs) == 0) { + term->field = Field::Url; + } else if (field.compare("notes", cs) == 0) { + term->field = Field::Notes; + } else if (field.startsWith("attr", cs)) { + term->field = Field::Attribute; + } else if (field.startsWith("attach", cs)) { + term->field = Field::Attachment; + } else { + term->field = Field::Undefined; + } + } + + terms.append(term); + } + + return terms; } diff --git a/src/core/EntrySearcher.h b/src/core/EntrySearcher.h index 343734737..ec71a7ce1 100644 --- a/src/core/EntrySearcher.h +++ b/src/core/EntrySearcher.h @@ -20,6 +20,7 @@ #define KEEPASSX_ENTRYSEARCHER_H #include +#include class Group; class Entry; @@ -27,14 +28,42 @@ class Entry; class EntrySearcher { public: - QList search(const QString& searchTerm, const Group* group, Qt::CaseSensitivity caseSensitivity); + explicit EntrySearcher(bool caseSensitive = false); + + QList search(const QString& searchString, const Group* baseGroup, bool forceSearch = false); + QList searchEntries(const QString& searchString, const QList& entries); + + void setCaseSensitive(bool state); + bool isCaseSensitive(); private: - QList searchEntries(const QString& searchTerm, const Group* group, Qt::CaseSensitivity caseSensitivity); - QList matchEntry(const QString& searchTerm, Entry* entry, Qt::CaseSensitivity caseSensitivity); - bool wordMatch(const QString& word, Entry* entry, Qt::CaseSensitivity caseSensitivity); - bool matchGroup(const QString& searchTerm, const Group* group, Qt::CaseSensitivity caseSensitivity); - bool wordMatch(const QString& word, const Group* group, Qt::CaseSensitivity caseSensitivity); + bool searchEntryImpl(const QString& searchString, Entry* entry); + + enum class Field { + Undefined, + Title, + Username, + Password, + Url, + Notes, + Attribute, + Attachment + }; + + struct SearchTerm + { + Field field; + QString word; + QRegularExpression regex; + bool exclude; + }; + + QList > parseSearchTerms(const QString& searchString); + + bool m_caseSensitive; + QRegularExpression m_termParser; + + friend class TestEntrySearcher; }; #endif // KEEPASSX_ENTRYSEARCHER_H diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp index ded3a1651..362cfa937 100644 --- a/src/core/Tools.cpp +++ b/src/core/Tools.cpp @@ -26,6 +26,8 @@ #include #include #include +#include + #include #include @@ -199,4 +201,31 @@ void wait(int ms) } } +// Escape common regex symbols except for *, ?, and | +auto regexEscape = QRegularExpression(R"re(([-[\]{}()+.,\\\/^$#]))re"); + +QRegularExpression convertToRegex(const QString& string, bool useWildcards, bool exactMatch, bool caseSensitive) +{ + QString pattern = string; + + // Wildcard support (*, ?, |) + if (useWildcards) { + pattern.replace(regexEscape, "\\\\1"); + pattern.replace("*", ".*"); + pattern.replace("?", "."); + } + + // Exact modifier + if (exactMatch) { + pattern = "^" + pattern + "$"; + } + + auto regex = QRegularExpression(pattern); + if (!caseSensitive) { + regex.setPatternOptions(QRegularExpression::CaseInsensitiveOption); + } + + return regex; +} + } // namespace Tools diff --git a/src/core/Tools.h b/src/core/Tools.h index 13d9869f7..37214f069 100644 --- a/src/core/Tools.h +++ b/src/core/Tools.h @@ -27,6 +27,7 @@ #include class QIODevice; +class QRegularExpression; namespace Tools { @@ -38,6 +39,8 @@ bool isHex(const QByteArray& ba); bool isBase64(const QByteArray& ba); void sleep(int ms); void wait(int ms); +QRegularExpression convertToRegex(const QString& string, bool useWildcards = false, bool exactMatch = false, + bool caseSensitive = false); template RandomAccessIterator binaryFind(RandomAccessIterator begin, RandomAccessIterator end, const T& value) diff --git a/src/gui/Application.cpp b/src/gui/Application.cpp index b67f542c6..03cd0e551 100644 --- a/src/gui/Application.cpp +++ b/src/gui/Application.cpp @@ -49,7 +49,6 @@ namespace Application::Application(int& argc, char** argv) : QApplication(argc, argv) - , m_mainWindow(nullptr) #ifdef Q_OS_UNIX , m_unixSignalNotifier(nullptr) #endif @@ -143,16 +142,6 @@ Application::~Application() } } -QWidget* Application::mainWindow() const -{ - return m_mainWindow; -} - -void Application::setMainWindow(QWidget* mainWindow) -{ - m_mainWindow = mainWindow; -} - bool Application::event(QEvent* event) { // Handle Apple QFileOpenEvent from finder (double click on .kdbx file) diff --git a/src/gui/Application.h b/src/gui/Application.h index 7b1a77f60..9a3ef756b 100644 --- a/src/gui/Application.h +++ b/src/gui/Application.h @@ -37,9 +37,7 @@ class Application : public QApplication public: Application(int& argc, char** argv); - QWidget* mainWindow() const; ~Application() override; - void setMainWindow(QWidget* mainWindow); bool event(QEvent* event) override; bool isAlreadyRunning() const; @@ -60,8 +58,6 @@ private slots: void socketReadyRead(); private: - QWidget* m_mainWindow; - #if defined(Q_OS_UNIX) /** * Register Unix signals such as SIGINT and SIGTERM for clean shutdown. diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 3faf43a65..5c8d7bc9d 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -100,7 +100,7 @@ DatabaseWidget::DatabaseWidget(Database* db, QWidget* parent) m_entryView = new EntryView(rightHandSideWidget); m_entryView->setObjectName("entryView"); m_entryView->setContextMenuPolicy(Qt::CustomContextMenu); - m_entryView->setGroup(db->rootGroup()); + m_entryView->displayGroup(db->rootGroup()); connect(m_entryView, SIGNAL(customContextMenuRequested(QPoint)), SLOT(emitEntryContextMenuRequested(QPoint))); // Add a notification for when we are searching @@ -209,7 +209,7 @@ DatabaseWidget::DatabaseWidget(Database* db, QWidget* parent) m_fileWatchUnblockTimer.setSingleShot(true); m_ignoreAutoReload = false; - m_searchCaseSensitive = false; + m_EntrySearcher = new EntrySearcher(false); m_searchLimitGroup = config()->get("SearchLimitGroup", false).toBool(); #ifdef WITH_XC_SSHAGENT @@ -227,6 +227,7 @@ DatabaseWidget::DatabaseWidget(Database* db, QWidget* parent) DatabaseWidget::~DatabaseWidget() { + delete m_EntrySearcher; } DatabaseWidget::Mode DatabaseWidget::currentMode() const @@ -291,7 +292,7 @@ bool DatabaseWidget::isUsernamesHidden() const /** * Set state of entry view 'Hide Usernames' setting */ -void DatabaseWidget::setUsernamesHidden(const bool hide) +void DatabaseWidget::setUsernamesHidden(bool hide) { m_entryView->setUsernamesHidden(hide); } @@ -307,7 +308,7 @@ bool DatabaseWidget::isPasswordsHidden() const /** * Set state of entry view 'Hide Passwords' setting */ -void DatabaseWidget::setPasswordsHidden(const bool hide) +void DatabaseWidget::setPasswordsHidden(bool hide) { m_entryView->setPasswordsHidden(hide); } @@ -892,6 +893,14 @@ void DatabaseWidget::entryActivationSignalReceived(Entry* entry, EntryModel::Mod setupTotp(); } break; + case EntryModel::ParentGroup: + // Call this first to clear out of search mode, otherwise + // the desired entry is not properly selected + endSearch(); + emit clearSearch(); + m_groupView->setCurrentGroup(entry->group()); + m_entryView->setCurrentEntry(entry); + break; // TODO: switch to 'Notes' tab in details view/pane // case EntryModel::Notes: // break; @@ -1012,17 +1021,15 @@ void DatabaseWidget::search(const QString& searchtext) emit searchModeAboutToActivate(); - Qt::CaseSensitivity caseSensitive = m_searchCaseSensitive ? Qt::CaseSensitive : Qt::CaseInsensitive; - Group* searchGroup = m_searchLimitGroup ? currentGroup() : m_db->rootGroup(); - QList searchResult = EntrySearcher().search(searchtext, searchGroup, caseSensitive); + QList searchResult = m_EntrySearcher->search(searchtext, searchGroup); - m_entryView->setEntryList(searchResult); + m_entryView->displaySearch(searchResult); m_lastSearchText = searchtext; // Display a label detailing our search results - if (searchResult.size() > 0) { + if (!searchResult.isEmpty()) { m_searchingLabel->setText(tr("Search Results (%1)").arg(searchResult.size())); } else { m_searchingLabel->setText(tr("No Results")); @@ -1035,7 +1042,7 @@ void DatabaseWidget::search(const QString& searchtext) void DatabaseWidget::setSearchCaseSensitive(bool state) { - m_searchCaseSensitive = state; + m_EntrySearcher->setCaseSensitive(state); refreshSearch(); } @@ -1047,11 +1054,14 @@ void DatabaseWidget::setSearchLimitGroup(bool state) void DatabaseWidget::onGroupChanged(Group* group) { - // Intercept group changes if in search mode - if (isInSearchMode()) { + if (isInSearchMode() && m_searchLimitGroup) { + // Perform new search if we are limiting search to the current group search(m_lastSearchText); + } else if (isInSearchMode()) { + // Otherwise cancel search + emit clearSearch(); } else { - m_entryView->setGroup(group); + m_entryView->displayGroup(group); } } @@ -1066,7 +1076,7 @@ void DatabaseWidget::endSearch() emit listModeAboutToActivate(); // Show the normal entry view of the current group - m_entryView->setGroup(currentGroup()); + m_entryView->displayGroup(currentGroup()); emit listModeActivated(); } diff --git a/src/gui/DatabaseWidget.h b/src/gui/DatabaseWidget.h index a5cf538d7..d0c4e2042 100644 --- a/src/gui/DatabaseWidget.h +++ b/src/gui/DatabaseWidget.h @@ -37,6 +37,7 @@ class EditEntryWidget; class EditGroupWidget; class Entry; class EntryView; +class EntrySearcher; class Group; class GroupView; class KeePass1OpenWidget; @@ -93,9 +94,9 @@ public: QList previewSplitterSizes() const; void setPreviewSplitterSizes(const QList& sizes); bool isUsernamesHidden() const; - void setUsernamesHidden(const bool hide); + void setUsernamesHidden(bool hide); bool isPasswordsHidden() const; - void setPasswordsHidden(const bool hide); + void setPasswordsHidden(bool hide); QByteArray entryViewState() const; bool setEntryViewState(const QByteArray& state) const; void clearAllWidgets(); @@ -138,7 +139,7 @@ signals: void mainSplitterSizesChanged(); void previewSplitterSizesChanged(); void entryViewStateChanged(); - void updateSearch(QString text); + void clearSearch(); public slots: void createEntry(); @@ -246,8 +247,8 @@ private: QString m_databaseFileName; // Search state + EntrySearcher* m_EntrySearcher; QString m_lastSearchText; - bool m_searchCaseSensitive; bool m_searchLimitGroup; // CSV import state diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index d9f9a0557..67e453392 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -109,12 +109,17 @@ public: const QString MainWindow::BaseWindowTitle = "KeePassXC"; +MainWindow* g_MainWindow = nullptr; +MainWindow* getMainWindow() { return g_MainWindow; } + MainWindow::MainWindow() : m_ui(new Ui::MainWindow()) , m_trayIcon(nullptr) , m_appExitCalled(false) , m_appExiting(false) { + g_MainWindow = this; + m_ui->setupUi(this); #if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) && !defined(QT_NO_DBUS) diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index caf2a0c58..38b1c9308 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -148,8 +148,12 @@ private: bool m_appExiting; }; -#define KEEPASSXC_MAIN_WINDOW \ - (qobject_cast(qApp) ? qobject_cast(qobject_cast(qApp)->mainWindow()) \ - : nullptr) +/** + * Return instance of MainWindow created on app load + * non-gui instances will return nullptr + * + * @return MainWindow instance or nullptr + */ +MainWindow* getMainWindow(); #endif // KEEPASSX_MAINWINDOW_H diff --git a/src/gui/SearchHelpWidget.ui b/src/gui/SearchHelpWidget.ui new file mode 100644 index 000000000..daa3a851e --- /dev/null +++ b/src/gui/SearchHelpWidget.ui @@ -0,0 +1,458 @@ + + + SearchHelpWidget + + + + 0 + 0 + 334 + 249 + + + + Search Help + + + false + + + #SearchHelpWidget { background-color: #ffffff } + + + QFrame::Box + + + QFrame::Plain + + + + 6 + + + QLayout::SetDefaultConstraint + + + 5 + + + 5 + + + 5 + + + 5 + + + + + + 0 + 0 + + + + + 75 + true + + + + Search terms are as follows: [modifiers][field:]["]term["] + + + + + + + + 0 + 0 + + + + + 75 + true + + + + Every search term must match (ie, logical AND) + + + + + + + + + + 50 + false + + + + Modifiers + + + + 8 + + + 8 + + + 9 + + + 10 + + + 9 + + + 9 + + + + + + 10 + 0 + + + + + 75 + true + + + + ! + + + false + + + Qt::AlignCenter + + + + + + + exclude term from results + + + + + + + match term exactly + + + + + + + + 10 + 0 + + + + + 75 + true + + + + * + + + Qt::AlignCenter + + + + + + + use regex in term + + + + + + + + 10 + 0 + + + + + 75 + true + + + + + + + + Qt::AlignCenter + + + + + + + + + + + 50 + false + + + + Fields + + + false + + + + 15 + + + 10 + + + 15 + + + 8 + + + 5 + + + + + username + + + + + + + password + + + + + + + title + + + + + + + url + + + + + + + notes + + + + + + + attribute + + + + + + + attachment + + + + + + + + + + + + + + Term Wildcards + + + + 8 + + + 8 + + + + + + 10 + 0 + + + + + 75 + true + + + + * + + + false + + + Qt::AlignCenter + + + + + + + + 10 + 0 + + + + + 75 + true + + + + ? + + + Qt::AlignCenter + + + + + + + + 10 + 0 + + + + + 75 + true + + + + | + + + Qt::AlignCenter + + + + + + + match anything + + + + + + + match one + + + + + + + logical OR + + + + + + + + + + Examples + + + + 8 + + + + + + 0 + 0 + + + + user:name1 url:google + + + + + + + + 0 + 0 + + + + user:"name1|name2" + + + + + + + + 0 + 0 + + + + +user:name1 *notes:"secret \d" + + + + + + + + + + + + + diff --git a/src/gui/SearchWidget.cpp b/src/gui/SearchWidget.cpp index 40c63036c..cde899576 100644 --- a/src/gui/SearchWidget.cpp +++ b/src/gui/SearchWidget.cpp @@ -18,6 +18,7 @@ #include "SearchWidget.h" #include "ui_SearchWidget.h" +#include "ui_SearchHelpWidget.h" #include #include @@ -26,19 +27,29 @@ #include "core/Config.h" #include "core/FilePath.h" +#include "gui/widgets/PopupHelpWidget.h" SearchWidget::SearchWidget(QWidget* parent) : QWidget(parent) , m_ui(new Ui::SearchWidget()) + , m_searchTimer(new QTimer(this)) + , m_clearSearchTimer(new QTimer(this)) { m_ui->setupUi(this); - m_searchTimer = new QTimer(this); + m_helpWidget = new PopupHelpWidget(m_ui->searchEdit); + m_helpWidget->setOffset(QPoint(0,1)); + Ui::SearchHelpWidget helpUi; + helpUi.setupUi(m_helpWidget); + m_searchTimer->setSingleShot(true); + m_clearSearchTimer->setSingleShot(true); connect(m_ui->searchEdit, SIGNAL(textChanged(QString)), SLOT(startSearchTimer())); connect(m_ui->clearIcon, SIGNAL(triggered(bool)), m_ui->searchEdit, SLOT(clear())); + connect(m_ui->helpIcon, SIGNAL(triggered()), SLOT(toggleHelp())); connect(m_searchTimer, SIGNAL(timeout()), this, SLOT(startSearch())); + connect(m_clearSearchTimer, SIGNAL(timeout()), m_ui->searchEdit, SLOT(clear())); connect(this, SIGNAL(escapePressed()), m_ui->searchEdit, SLOT(clear())); new QShortcut(QKeySequence::Find, this, SLOT(searchFocus()), nullptr, Qt::ApplicationShortcut); @@ -62,6 +73,9 @@ SearchWidget::SearchWidget(QWidget* parent) m_ui->searchIcon->setMenu(searchMenu); m_ui->searchEdit->addAction(m_ui->searchIcon, QLineEdit::LeadingPosition); + m_ui->helpIcon->setIcon(filePath()->icon("actions", "system-help")); + m_ui->searchEdit->addAction(m_ui->helpIcon, QLineEdit::TrailingPosition); + m_ui->clearIcon->setIcon(filePath()->icon("actions", "edit-clear-locationbar-rtl")); m_ui->clearIcon->setVisible(false); m_ui->searchEdit->addAction(m_ui->clearIcon, QLineEdit::TrailingPosition); @@ -83,13 +97,6 @@ bool SearchWidget::eventFilter(QObject* obj, QEvent* event) if (keyEvent->key() == Qt::Key_Escape) { emit escapePressed(); return true; - } else if (keyEvent->matches(QKeySequence::Copy)) { - // If Control+C is pressed in the search edit when no text - // is selected, copy the password of the current entry - if (!m_ui->searchEdit->hasSelectedText()) { - emit copyPressed(); - return true; - } } else if (keyEvent->matches(QKeySequence::MoveToNextLine)) { if (m_ui->searchEdit->cursorPosition() == m_ui->searchEdit->text().length()) { // If down is pressed at EOL, move the focus to the entry view @@ -101,9 +108,14 @@ bool SearchWidget::eventFilter(QObject* obj, QEvent* event) return true; } } + } else if (event->type() == QEvent::FocusOut) { + // Auto-clear search after 5 minutes + m_clearSearchTimer->start(300000); + } else if (event->type() == QEvent::FocusIn) { + m_clearSearchTimer->stop(); } - return QObject::eventFilter(obj, event); + return QWidget::eventFilter(obj, event); } void SearchWidget::connectSignals(SignalMultiplexer& mx) @@ -113,6 +125,7 @@ void SearchWidget::connectSignals(SignalMultiplexer& mx) mx.connect(this, SIGNAL(limitGroupChanged(bool)), SLOT(setSearchLimitGroup(bool))); mx.connect(this, SIGNAL(copyPressed()), SLOT(copyPassword())); mx.connect(this, SIGNAL(downPressed()), SLOT(setFocus())); + mx.connect(SIGNAL(clearSearch()), m_ui->searchEdit, SLOT(clear())); mx.connect(m_ui->searchEdit, SIGNAL(returnPressed()), SLOT(switchToEntryEdit())); } @@ -136,7 +149,7 @@ void SearchWidget::startSearchTimer() if (!m_searchTimer->isActive()) { m_searchTimer->stop(); } - m_searchTimer->start(100); + m_searchTimer->start(300); } void SearchWidget::startSearch() @@ -179,3 +192,12 @@ void SearchWidget::searchFocus() m_ui->searchEdit->setFocus(); m_ui->searchEdit->selectAll(); } + +void SearchWidget::toggleHelp() +{ + if (m_helpWidget->isVisible()) { + m_helpWidget->hide(); + } else { + m_helpWidget->show(); + } +} diff --git a/src/gui/SearchWidget.h b/src/gui/SearchWidget.h index 0ec3287c1..43dd76430 100644 --- a/src/gui/SearchWidget.h +++ b/src/gui/SearchWidget.h @@ -30,20 +30,25 @@ namespace Ui class SearchWidget; } +class PopupHelpWidget; + class SearchWidget : public QWidget { Q_OBJECT public: explicit SearchWidget(QWidget* parent = nullptr); - ~SearchWidget(); + ~SearchWidget() override; + + Q_DISABLE_COPY(SearchWidget) void connectSignals(SignalMultiplexer& mx); void setCaseSensitive(bool state); void setLimitGroup(bool state); protected: - bool eventFilter(QObject* obj, QEvent* event); + // Filter key presses in the search field + bool eventFilter(QObject* obj, QEvent* event) override; signals: void search(const QString& text); @@ -63,14 +68,15 @@ private slots: void updateCaseSensitive(); void updateLimitGroup(); void searchFocus(); + void toggleHelp(); private: const QScopedPointer m_ui; + PopupHelpWidget* m_helpWidget; QTimer* m_searchTimer; + QTimer* m_clearSearchTimer; QAction* m_actionCaseSensitive; QAction* m_actionLimitGroup; - - Q_DISABLE_COPY(SearchWidget) }; #endif // SEARCHWIDGET_H diff --git a/src/gui/SearchWidget.ui b/src/gui/SearchWidget.ui index 438a242c7..93fbbdee5 100644 --- a/src/gui/SearchWidget.ui +++ b/src/gui/SearchWidget.ui @@ -34,6 +34,9 @@ Qt::Horizontal + + QSizePolicy::Minimum + 30 @@ -44,6 +47,18 @@ + + + 0 + 0 + + + + + 0 + 0 + + padding:3px @@ -66,6 +81,11 @@ Clear + + + Search Help + + searchEdit diff --git a/src/gui/entry/EntryModel.cpp b/src/gui/entry/EntryModel.cpp index 2edc49b25..194c4b571 100644 --- a/src/gui/entry/EntryModel.cpp +++ b/src/gui/entry/EntryModel.cpp @@ -72,10 +72,9 @@ void EntryModel::setGroup(Group* group) makeConnections(group); endResetModel(); - emit switchedToListMode(); } -void EntryModel::setEntryList(const QList& entries) +void EntryModel::setEntries(const QList& entries) { beginResetModel(); @@ -109,7 +108,6 @@ void EntryModel::setEntryList(const QList& entries) } endResetModel(); - emit switchedToSearchMode(); } int EntryModel::rowCount(const QModelIndex& parent) const diff --git a/src/gui/entry/EntryModel.h b/src/gui/entry/EntryModel.h index 3e9f2824a..5f405bd41 100644 --- a/src/gui/entry/EntryModel.h +++ b/src/gui/entry/EntryModel.h @@ -60,22 +60,20 @@ public: QStringList mimeTypes() const override; QMimeData* mimeData(const QModelIndexList& indexes) const override; - void setEntryList(const QList& entries); - void setPaperClipPixmap(const QPixmap& paperclip); + void setGroup(Group* group); + void setEntries(const QList& entries); + bool isUsernamesHidden() const; + void setUsernamesHidden(bool hide); bool isPasswordsHidden() const; + void setPasswordsHidden(bool hide); + + void setPaperClipPixmap(const QPixmap& paperclip); signals: - void switchedToListMode(); - void switchedToSearchMode(); void usernamesHiddenChanged(); void passwordsHiddenChanged(); -public slots: - void setGroup(Group* group); - void setUsernamesHidden(bool hide); - void setPasswordsHidden(bool hide); - private slots: void entryAboutToAdd(Entry* entry); void entryAdded(Entry* entry); diff --git a/src/gui/entry/EntryView.cpp b/src/gui/entry/EntryView.cpp index ecdccd7bb..64eca5ee3 100644 --- a/src/gui/entry/EntryView.cpp +++ b/src/gui/entry/EntryView.cpp @@ -50,11 +50,7 @@ EntryView::EntryView(QWidget* parent) setDefaultDropAction(Qt::MoveAction); connect(this, SIGNAL(doubleClicked(QModelIndex)), SLOT(emitEntryActivated(QModelIndex))); - connect(selectionModel(), - SIGNAL(selectionChanged(QItemSelection,QItemSelection)), SIGNAL(entrySelectionChanged())); - - connect(m_model, SIGNAL(switchedToListMode()), SLOT(switchToListMode())); - connect(m_model, SIGNAL(switchedToSearchMode()), SLOT(switchToSearchMode())); + connect(selectionModel(), SIGNAL(selectionChanged(QItemSelection,QItemSelection)), SIGNAL(entrySelectionChanged())); connect(m_model, SIGNAL(usernamesHiddenChanged()), SIGNAL(viewStateChanged())); connect(m_model, SIGNAL(passwordsHiddenChanged()), SIGNAL(viewStateChanged())); @@ -62,9 +58,9 @@ EntryView::EntryView(QWidget* parent) m_headerMenu->setTitle(tr("Customize View")); m_headerMenu->addSection(tr("Customize View")); - m_hideUsernamesAction = m_headerMenu->addAction(tr("Hide Usernames"), m_model, SLOT(setUsernamesHidden(bool))); + m_hideUsernamesAction = m_headerMenu->addAction(tr("Hide Usernames"), this, SLOT(setUsernamesHidden(bool))); m_hideUsernamesAction->setCheckable(true); - m_hidePasswordsAction = m_headerMenu->addAction(tr("Hide Passwords"), m_model, SLOT(setPasswordsHidden(bool))); + m_hidePasswordsAction = m_headerMenu->addAction(tr("Hide Passwords"), this, SLOT(setPasswordsHidden(bool))); m_hidePasswordsAction->setCheckable(true); m_headerMenu->addSeparator(); @@ -158,16 +154,25 @@ void EntryView::focusOutEvent(QFocusEvent* event) QTreeView::focusOutEvent(event); } -void EntryView::setGroup(Group* group) +void EntryView::displayGroup(Group* group) { m_model->setGroup(group); + header()->hideSection(EntryModel::ParentGroup); setFirstEntryActive(); + m_inSearchMode = false; } -void EntryView::setEntryList(const QList& entries) +void EntryView::displaySearch(const QList& entries) { - m_model->setEntryList(entries); + m_model->setEntries(entries); + header()->showSection(EntryModel::ParentGroup); + + // Reset sort column to 'Group', overrides DatabaseWidgetStateSync + m_sortModel->sort(EntryModel::ParentGroup, Qt::AscendingOrder); + sortByColumn(EntryModel::ParentGroup, Qt::AscendingOrder); + setFirstEntryActive(); + m_inSearchMode = true; } void EntryView::setFirstEntryActive() @@ -227,39 +232,6 @@ Entry* EntryView::entryFromIndex(const QModelIndex& index) } } -/** - * Switch to list mode, i.e. list entries of group - */ -void EntryView::switchToListMode() -{ - if (!m_inSearchMode) { - return; - } - - header()->hideSection(EntryModel::ParentGroup); - m_inSearchMode = false; -} - -/** - * Switch to search mode, i.e. list search results - */ -void EntryView::switchToSearchMode() -{ - if (m_inSearchMode) { - return; - } - - header()->showSection(EntryModel::ParentGroup); - - // Always set sorting to column 'Group', as it does not feel right to - // have the last known sort configuration of search view restored by - // 'DatabaseWidgetStateSync', which is what happens without this - m_sortModel->sort(EntryModel::ParentGroup, Qt::AscendingOrder); - sortByColumn(EntryModel::ParentGroup, Qt::AscendingOrder); - - m_inSearchMode = true; -} - /** * Get current state of 'Hide Usernames' setting (NOTE: just pass-through for * m_model) @@ -272,7 +244,7 @@ bool EntryView::isUsernamesHidden() const /** * Set state of 'Hide Usernames' setting (NOTE: just pass-through for m_model) */ -void EntryView::setUsernamesHidden(const bool hide) +void EntryView::setUsernamesHidden(bool hide) { bool block = m_hideUsernamesAction->signalsBlocked(); m_hideUsernamesAction->blockSignals(true); @@ -294,7 +266,7 @@ bool EntryView::isPasswordsHidden() const /** * Set state of 'Hide Passwords' setting (NOTE: just pass-through for m_model) */ -void EntryView::setPasswordsHidden(const bool hide) +void EntryView::setPasswordsHidden(bool hide) { bool block = m_hidePasswordsAction->signalsBlocked(); m_hidePasswordsAction->blockSignals(true); diff --git a/src/gui/entry/EntryView.h b/src/gui/entry/EntryView.h index 2030f0ec7..766699599 100644 --- a/src/gui/entry/EntryView.h +++ b/src/gui/entry/EntryView.h @@ -39,25 +39,26 @@ public: Entry* currentEntry(); void setCurrentEntry(Entry* entry); Entry* entryFromIndex(const QModelIndex& index); - void setEntryList(const QList& entries); bool inSearchMode(); int numberOfSelectedEntries(); void setFirstEntryActive(); bool isUsernamesHidden() const; - void setUsernamesHidden(const bool hide); bool isPasswordsHidden() const; - void setPasswordsHidden(const bool hide); QByteArray viewState() const; bool setViewState(const QByteArray& state); -public slots: - void setGroup(Group* group); + void displayGroup(Group* group); + void displaySearch(const QList& entries); signals: void entryActivated(Entry* entry, EntryModel::ModelColumn column); void entrySelectionChanged(); void viewStateChanged(); +public slots: + void setUsernamesHidden(bool hide); + void setPasswordsHidden(bool hide); + protected: void keyPressEvent(QKeyEvent* event) override; void focusInEvent(QFocusEvent* event) override; @@ -65,8 +66,6 @@ protected: private slots: void emitEntryActivated(const QModelIndex& index); - void switchToListMode(); - void switchToSearchMode(); void showHeaderMenu(const QPoint& position); void toggleColumnVisibility(QAction* action); void fitColumnsToWindow(); diff --git a/src/gui/masterkey/KeyFileEditWidget.cpp b/src/gui/masterkey/KeyFileEditWidget.cpp index 14ac4879c..c694e2c5a 100644 --- a/src/gui/masterkey/KeyFileEditWidget.cpp +++ b/src/gui/masterkey/KeyFileEditWidget.cpp @@ -45,7 +45,7 @@ bool KeyFileEditWidget::addToCompositeKey(QSharedPointer key) } if (fileKey->type() != FileKey::Hashed) { - QMessageBox::warning(KEEPASSXC_MAIN_WINDOW, + QMessageBox::warning(getMainWindow(), tr("Legacy key file format"), tr("You are using a legacy key file format which may become\n" "unsupported in the future.\n\n" @@ -100,7 +100,7 @@ void KeyFileEditWidget::createKeyFile() QString errorMsg; bool created = FileKey::create(fileName, &errorMsg); if (!created) { - MessageBox::critical(KEEPASSXC_MAIN_WINDOW, tr("Error creating key file"), + MessageBox::critical(getMainWindow(), tr("Error creating key file"), tr("Unable to create key file: %1").arg(errorMsg), QMessageBox::Button::Ok); } else { m_compUi->keyFileCombo->setEditText(fileName); diff --git a/src/gui/widgets/PopupHelpWidget.cpp b/src/gui/widgets/PopupHelpWidget.cpp new file mode 100644 index 000000000..45e19e81e --- /dev/null +++ b/src/gui/widgets/PopupHelpWidget.cpp @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2018 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 "PopupHelpWidget.h" + +#include + +#include "gui/MainWindow.h" + +PopupHelpWidget::PopupHelpWidget(QWidget* parent) + : QFrame(parent) + , m_parentWindow(parent->window()) + , m_appWindow(getMainWindow()) + , m_offset({0, 0}) + , m_corner(Qt::BottomLeftCorner) +{ + Q_ASSERT(parent); + + setWindowFlags(Qt::FramelessWindowHint | Qt::Tool); + hide(); + + m_appWindow->installEventFilter(this); + parent->installEventFilter(this); +} + +PopupHelpWidget::~PopupHelpWidget() +{ + m_parentWindow->removeEventFilter(this); + parentWidget()->removeEventFilter(this); +} + +void PopupHelpWidget::setOffset(const QPoint& offset) +{ + m_offset = offset; + if (isVisible()) { + alignWithParent(); + } +} + +void PopupHelpWidget::setPosition(Qt::Corner corner) +{ + m_corner = corner; + if (isVisible()) { + alignWithParent(); + } +} + +bool PopupHelpWidget::eventFilter(QObject* obj, QEvent* event) +{ + if (obj == parentWidget() && event->type() == QEvent::FocusOut) { + hide(); + } else if (obj == m_appWindow && (event->type() == QEvent::Move || event->type() == QEvent::Resize)) { + if (isVisible()) { + alignWithParent(); + } + } + return QFrame::eventFilter(obj, event); +} + +void PopupHelpWidget::showEvent(QShowEvent* event) +{ + alignWithParent(); + QFrame::showEvent(event); +} + +void PopupHelpWidget::alignWithParent() +{ + QPoint pos; + switch (m_corner) { + case Qt::TopLeftCorner: + pos = parentWidget()->geometry().topLeft() + m_offset - QPoint(0, height()); + break; + case Qt::TopRightCorner: + pos = parentWidget()->geometry().topRight() + m_offset - QPoint(width(), height()); + break; + case Qt::BottomRightCorner: + pos = parentWidget()->geometry().bottomRight() + m_offset - QPoint(width(), 0); + break; + default: + pos = parentWidget()->geometry().bottomLeft() + m_offset; + break; + } + + move(m_parentWindow->mapToGlobal(pos)); +} \ No newline at end of file diff --git a/src/gui/widgets/PopupHelpWidget.h b/src/gui/widgets/PopupHelpWidget.h new file mode 100644 index 000000000..66dac2b30 --- /dev/null +++ b/src/gui/widgets/PopupHelpWidget.h @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2018 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_POPUPHELPWIDGET_H +#define KEEPASSXC_POPUPHELPWIDGET_H + +#include +#include + +class PopupHelpWidget : public QFrame +{ + Q_OBJECT +public: + explicit PopupHelpWidget(QWidget* parent); + ~PopupHelpWidget() override; + + void setOffset(const QPoint& offset); + void setPosition(Qt::Corner corner); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; + void showEvent(QShowEvent* event) override; + +private: + void alignWithParent(); + QPointer m_parentWindow; + QPointer m_appWindow; + + QPoint m_offset; + Qt::Corner m_corner; +}; + + +#endif //KEEPASSXC_POPUPHELPWIDGET_H diff --git a/src/keys/YkChallengeResponseKey.cpp b/src/keys/YkChallengeResponseKey.cpp index b2a40bd23..ade1b6324 100644 --- a/src/keys/YkChallengeResponseKey.cpp +++ b/src/keys/YkChallengeResponseKey.cpp @@ -37,9 +37,9 @@ YkChallengeResponseKey::YkChallengeResponseKey(int slot, bool blocking) , m_slot(slot) , m_blocking(blocking) { - if (KEEPASSXC_MAIN_WINDOW) { - connect(this, SIGNAL(userInteractionRequired()), KEEPASSXC_MAIN_WINDOW, SLOT(showYubiKeyPopup())); - connect(this, SIGNAL(userConfirmed()), KEEPASSXC_MAIN_WINDOW, SLOT(hideYubiKeyPopup())); + if (getMainWindow()) { + connect(this, SIGNAL(userInteractionRequired()), getMainWindow(), SLOT(showYubiKeyPopup())); + connect(this, SIGNAL(userConfirmed()), getMainWindow(), SLOT(hideYubiKeyPopup())); } } diff --git a/src/main.cpp b/src/main.cpp index 9764c52d1..c811fe62c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -111,7 +111,6 @@ int main(int argc, char** argv) } MainWindow mainWindow; - app.setMainWindow(&mainWindow); QObject::connect(&app, SIGNAL(anotherInstanceStarted()), &mainWindow, SLOT(bringToFront())); QObject::connect(&app, SIGNAL(applicationActivated()), &mainWindow, SLOT(bringToFront())); QObject::connect(&app, SIGNAL(openFile(QString)), &mainWindow, SLOT(openDatabase(QString))); diff --git a/tests/TestEntryModel.cpp b/tests/TestEntryModel.cpp index 49939e256..e32de2466 100644 --- a/tests/TestEntryModel.cpp +++ b/tests/TestEntryModel.cpp @@ -307,7 +307,7 @@ void TestEntryModel::testProxyModel() QList entryList; entryList << entry; - modelSource->setEntryList(entryList); + modelSource->setEntries(entryList); /** * @author Fonic @@ -346,7 +346,7 @@ void TestEntryModel::testDatabaseDelete() Entry* entry2 = new Entry(); entry2->setGroup(db2->rootGroup()); - model->setEntryList(QList() << entry1 << entry2); + model->setEntries(QList() << entry1 << entry2); QCOMPARE(model->rowCount(), 2); diff --git a/tests/TestEntrySearcher.cpp b/tests/TestEntrySearcher.cpp index 659f7a489..eee9b9101 100644 --- a/tests/TestEntrySearcher.cpp +++ b/tests/TestEntrySearcher.cpp @@ -20,25 +20,34 @@ QTEST_GUILESS_MAIN(TestEntrySearcher) -void TestEntrySearcher::initTestCase() +void TestEntrySearcher::init() { - m_groupRoot = new Group(); + m_rootGroup = new Group(); } -void TestEntrySearcher::cleanupTestCase() +void TestEntrySearcher::cleanup() { - delete m_groupRoot; + delete m_rootGroup; } void TestEntrySearcher::testSearch() { + /** + * Root + * - group1 (search disabled) + * - group11 + * - group2 + * - group21 + * - group211 + * - group2111 + */ Group* group1 = new Group(); Group* group2 = new Group(); Group* group3 = new Group(); - group1->setParent(m_groupRoot); - group2->setParent(m_groupRoot); - group3->setParent(m_groupRoot); + group1->setParent(m_rootGroup); + group2->setParent(m_rootGroup); + group3->setParent(m_rootGroup); Group* group11 = new Group(); @@ -53,50 +62,74 @@ void TestEntrySearcher::testSearch() group2111->setParent(group211); group1->setSearchingEnabled(Group::Disable); - group11->setSearchingEnabled(Group::Enable); Entry* eRoot = new Entry(); - eRoot->setNotes("test search term test"); - eRoot->setGroup(m_groupRoot); + eRoot->setTitle("test search term test"); + eRoot->setGroup(m_rootGroup); Entry* eRoot2 = new Entry(); eRoot2->setNotes("test term test"); - eRoot2->setGroup(m_groupRoot); + eRoot2->setGroup(m_rootGroup); + // Searching is disabled for these Entry* e1 = new Entry(); - e1->setNotes("test search term test"); + e1->setUsername("test search term test"); e1->setGroup(group1); Entry* e11 = new Entry(); e11->setNotes("test search term test"); e11->setGroup(group11); + // End searching disabled Entry* e2111 = new Entry(); - e2111->setNotes("test search term test"); + e2111->setTitle("test search term test"); e2111->setGroup(group2111); Entry* e2111b = new Entry(); e2111b->setNotes("test search test"); + e2111b->setUsername("user123"); + e2111b->setPassword("testpass"); e2111b->setGroup(group2111); Entry* e3 = new Entry(); - e3->setNotes("test search term test"); + e3->setUrl("test search term test"); e3->setGroup(group3); Entry* e3b = new Entry(); - e3b->setNotes("test search test"); + e3b->setTitle("test search test"); + e3b->setUsername("test@email.com"); + e3b->setPassword("realpass"); e3b->setGroup(group3); - m_searchResult = m_entrySearcher.search("search term", m_groupRoot, Qt::CaseInsensitive); + // Simple search term testing + m_searchResult = m_entrySearcher.search("search", m_rootGroup); + QCOMPARE(m_searchResult.count(), 5); + + m_searchResult = m_entrySearcher.search("search term", m_rootGroup); QCOMPARE(m_searchResult.count(), 3); - m_searchResult = m_entrySearcher.search("search term", group211, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search("search term", group211); QCOMPARE(m_searchResult.count(), 1); - m_searchResult = m_entrySearcher.search("search term", group11, Qt::CaseInsensitive); + // Test advanced search terms + m_searchResult = m_entrySearcher.search("password:testpass", m_rootGroup); QCOMPARE(m_searchResult.count(), 1); - m_searchResult = m_entrySearcher.search("search term", group1, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search("!user:email.com", m_rootGroup); + QCOMPARE(m_searchResult.count(), 5); + + m_searchResult = m_entrySearcher.search("*user:\".*@.*\\.com\"", m_rootGroup); + QCOMPARE(m_searchResult.count(), 1); + + m_searchResult = m_entrySearcher.search("+user:email", m_rootGroup); + QCOMPARE(m_searchResult.count(), 0); + + // Terms are logical AND together + m_searchResult = m_entrySearcher.search("password:pass user:user", m_rootGroup); + QCOMPARE(m_searchResult.count(), 1); + + // Parent group has search disabled + m_searchResult = m_entrySearcher.search("search term", group11); QCOMPARE(m_searchResult.count(), 0); } @@ -105,38 +138,74 @@ void TestEntrySearcher::testAndConcatenationInSearch() Entry* entry = new Entry(); entry->setNotes("abc def ghi"); entry->setTitle("jkl"); - entry->setGroup(m_groupRoot); + entry->setGroup(m_rootGroup); - m_searchResult = m_entrySearcher.search("", m_groupRoot, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search("", m_rootGroup); QCOMPARE(m_searchResult.count(), 1); - m_searchResult = m_entrySearcher.search("def", m_groupRoot, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search("def", m_rootGroup); QCOMPARE(m_searchResult.count(), 1); - m_searchResult = m_entrySearcher.search(" abc ghi ", m_groupRoot, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search(" abc ghi ", m_rootGroup); QCOMPARE(m_searchResult.count(), 1); - m_searchResult = m_entrySearcher.search("ghi ef", m_groupRoot, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search("ghi ef", m_rootGroup); QCOMPARE(m_searchResult.count(), 1); - m_searchResult = m_entrySearcher.search("abc ef xyz", m_groupRoot, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search("abc ef xyz", m_rootGroup); QCOMPARE(m_searchResult.count(), 0); - m_searchResult = m_entrySearcher.search("abc kl", m_groupRoot, Qt::CaseInsensitive); + m_searchResult = m_entrySearcher.search("abc kl", m_rootGroup); QCOMPARE(m_searchResult.count(), 1); } void TestEntrySearcher::testAllAttributesAreSearched() { Entry* entry = new Entry(); - entry->setGroup(m_groupRoot); + entry->setGroup(m_rootGroup); entry->setTitle("testTitle"); entry->setUsername("testUsername"); entry->setUrl("testUrl"); entry->setNotes("testNote"); - m_searchResult = - m_entrySearcher.search("testTitle testUsername testUrl testNote", m_groupRoot, Qt::CaseInsensitive); + // Default is to AND all terms together + m_searchResult = m_entrySearcher.search("testTitle testUsername testUrl testNote", m_rootGroup); QCOMPARE(m_searchResult.count(), 1); } + +void TestEntrySearcher::testSearchTermParser() +{ + // Test standard search terms + auto terms = m_entrySearcher.parseSearchTerms("-test \"quoted \\\"string\\\"\" user:user pass:\"test me\" noquote "); + + QCOMPARE(terms.length(), 5); + + QCOMPARE(terms[0]->field, EntrySearcher::Field::Undefined); + QCOMPARE(terms[0]->word, QString("test")); + QCOMPARE(terms[0]->exclude, true); + + QCOMPARE(terms[1]->field, EntrySearcher::Field::Undefined); + QCOMPARE(terms[1]->word, QString("quoted \\\"string\\\"")); + QCOMPARE(terms[1]->exclude, false); + + QCOMPARE(terms[2]->field, EntrySearcher::Field::Username); + QCOMPARE(terms[2]->word, QString("user")); + + QCOMPARE(terms[3]->field, EntrySearcher::Field::Password); + QCOMPARE(terms[3]->word, QString("test me")); + + QCOMPARE(terms[4]->field, EntrySearcher::Field::Undefined); + QCOMPARE(terms[4]->word, QString("noquote")); + + // Test wildcard and regex search terms + terms = m_entrySearcher.parseSearchTerms("+url:*.google.com *user:\\d+\\w{2}"); + + QCOMPARE(terms.length(), 2); + + QCOMPARE(terms[0]->field, EntrySearcher::Field::Url); + QCOMPARE(terms[0]->regex.pattern(), QString("^.*\\.google\\.com$")); + + QCOMPARE(terms[1]->field, EntrySearcher::Field::Username); + QCOMPARE(terms[1]->regex.pattern(), QString("\\d+\\w{2}")); +} diff --git a/tests/TestEntrySearcher.h b/tests/TestEntrySearcher.h index 17d486c22..e10b1b544 100644 --- a/tests/TestEntrySearcher.h +++ b/tests/TestEntrySearcher.h @@ -28,15 +28,16 @@ class TestEntrySearcher : public QObject Q_OBJECT private slots: - void initTestCase(); - void cleanupTestCase(); + void init(); + void cleanup(); void testAndConcatenationInSearch(); void testSearch(); void testAllAttributesAreSearched(); + void testSearchTermParser(); private: - Group* m_groupRoot; + Group* m_rootGroup; EntrySearcher m_entrySearcher; QList m_searchResult; }; diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index 9b04dd18e..0adeabd95 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -802,10 +802,22 @@ void TestGui::testSearch() auto* clearButton = searchWidget->findChild("clearIcon"); QVERIFY(!clearButton->isVisible()); + auto* helpButton = searchWidget->findChild("helpIcon"); + auto* helpPanel = searchWidget->findChild("SearchHelpWidget"); + QVERIFY(helpButton->isVisible()); + QVERIFY(!helpPanel->isVisible()); + // Enter search QTest::mouseClick(searchTextEdit, Qt::LeftButton); QTRY_VERIFY(searchTextEdit->hasFocus()); QTRY_VERIFY(!clearButton->isVisible()); + // Show/Hide search help + helpButton->trigger(); + QTRY_VERIFY(helpPanel->isVisible()); + QTest::mouseClick(searchTextEdit, Qt::LeftButton); + QTRY_VERIFY(helpPanel->isVisible()); + helpButton->trigger(); + QTRY_VERIFY(!helpPanel->isVisible()); // Search for "ZZZ" QTest::keyClicks(searchTextEdit, "ZZZ"); QTRY_COMPARE(searchTextEdit->text(), QString("ZZZ")); @@ -841,9 +853,10 @@ void TestGui::testSearch() // Ensure Down focuses on entry view when search text is selected QTest::keyClick(searchTextEdit, Qt::Key_Down); QTRY_VERIFY(entryView->hasFocus()); + QCOMPARE(entryView->selectionModel()->currentIndex().row(), 0); // Test that password copies (entry has focus) QClipboard* clipboard = QApplication::clipboard(); - QTest::keyClick(searchTextEdit, Qt::Key_C, Qt::ControlModifier); + QTest::keyClick(entryView, Qt::Key_C, Qt::ControlModifier); QModelIndex searchedItem = entryView->model()->index(0, 1); Entry* searchedEntry = entryView->entryFromIndex(searchedItem); QTRY_COMPARE(searchedEntry->password(), clipboard->text());