Add advanced search term parser

* Support quoted strings & per-field searching
* Support regex and exact matching
* Simplify search sequence
* Make search widget larger
* Add regex converter to Tools namespace
This commit is contained in:
Jonathan White 2018-03-25 16:24:30 -04:00
parent 4b57fcb563
commit 4b983251cb
No known key found for this signature in database
GPG Key ID: 440FC65F2E0C6E01
14 changed files with 303 additions and 68 deletions

View File

@ -390,7 +390,7 @@ QList<Entry*> 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();

View File

@ -19,42 +19,91 @@
#include "EntrySearcher.h"
#include "core/Group.h"
#include "core/Tools.h"
QList<Entry*> 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
{
}
QList<Entry*> EntrySearcher::search(const QString& searchString, const Group* baseGroup, bool forceSearch)
{
Q_ASSERT(baseGroup);
QList<Entry*> results;
if (group->resolveSearchingEnabled()) {
results.append(searchEntries(searchTerm, group->entries(), caseSensitivity));
}
for (Group* childGroup : group->children()) {
if (childGroup->resolveSearchingEnabled()) {
results.append(searchEntries(searchTerm, childGroup->entries(), caseSensitivity));
for (const auto group : baseGroup->groupsRecursive(true)) {
if (forceSearch || group->resolveSearchingEnabled()) {
results.append(searchEntries(searchString, group->entries()));
}
}
return results;
}
QList<Entry*> EntrySearcher::searchEntries(const QString& searchTerm, const QList<Entry*>& entries,
Qt::CaseSensitivity caseSensitivity)
QList<Entry*> EntrySearcher::searchEntries(const QString& searchString, const QList<Entry*>& entries)
{
QList<Entry*> results;
for (Entry* entry : entries) {
if (matchEntry(searchTerm, entry, caseSensitivity)) {
if (searchEntryImpl(searchString, entry)) {
results.append(entry);
}
}
return results;
}
bool EntrySearcher::matchEntry(const QString& searchTerm, Entry* entry,
Qt::CaseSensitivity caseSensitivity)
void EntrySearcher::setCaseSensitive(bool state)
{
const QStringList wordList = searchTerm.split(QRegExp("\\s"), QString::SkipEmptyParts);
for (const QString& word : wordList) {
if (!wordMatch(word, entry, caseSensitivity)) {
m_caseSensitive = state;
}
bool EntrySearcher::isCaseSensitive()
{
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:
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) {
return false;
}
}
@ -62,10 +111,61 @@ bool EntrySearcher::matchEntry(const QString& searchTerm, Entry* entry,
return true;
}
bool EntrySearcher::wordMatch(const QString& word, Entry* entry, Qt::CaseSensitivity caseSensitivity)
QList<QSharedPointer<EntrySearcher::SearchTerm> > EntrySearcher::parseSearchTerms(const QString& searchString)
{
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);
auto terms = QList<QSharedPointer<SearchTerm> >();
auto results = m_termParser.globalMatch(searchString);
while (results.hasNext()) {
auto result = results.next();
auto term = QSharedPointer<SearchTerm>::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("-");
// 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;
}

View File

@ -20,6 +20,7 @@
#define KEEPASSX_ENTRYSEARCHER_H
#include <QString>
#include <QRegularExpression>
class Group;
class Entry;
@ -27,12 +28,42 @@ class Entry;
class EntrySearcher
{
public:
QList<Entry*> search(const QString& searchTerm, const Group* group, Qt::CaseSensitivity caseSensitivity);
explicit EntrySearcher(bool caseSensitive = false);
QList<Entry*> search(const QString& searchString, const Group* baseGroup, bool forceSearch = false);
QList<Entry*> searchEntries(const QString& searchString, const QList<Entry*>& entries);
void setCaseSensitive(bool state);
bool isCaseSensitive();
private:
QList<Entry*> searchEntries(const QString& searchTerm, const QList<Entry*>& entries, Qt::CaseSensitivity caseSensitivity);
bool matchEntry(const QString& searchTerm, Entry* entry, Qt::CaseSensitivity caseSensitivity);
bool wordMatch(const QString& word, Entry* entry, 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<QSharedPointer<SearchTerm> > parseSearchTerms(const QString& searchString);
bool m_caseSensitive;
QRegularExpression m_termParser;
friend class TestEntrySearcher;
};
#endif // KEEPASSX_ENTRYSEARCHER_H

View File

@ -26,6 +26,8 @@
#include <QImageReader>
#include <QLocale>
#include <QStringList>
#include <QRegularExpression>
#include <QElapsedTimer>
#include <cctype>
@ -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

View File

@ -27,6 +27,7 @@
#include <algorithm>
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 <typename RandomAccessIterator, typename T>
RandomAccessIterator binaryFind(RandomAccessIterator begin, RandomAccessIterator end, const T& value)

View File

@ -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
@ -1012,17 +1013,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<Entry*> searchResult = EntrySearcher().search(searchtext, searchGroup, caseSensitive);
QList<Entry*> searchResult = m_EntrySearcher->search(searchtext, searchGroup);
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 +1034,7 @@ void DatabaseWidget::search(const QString& searchtext)
void DatabaseWidget::setSearchCaseSensitive(bool state)
{
m_searchCaseSensitive = state;
m_EntrySearcher->setCaseSensitive(state);
refreshSearch();
}

View File

@ -37,6 +37,7 @@ class EditEntryWidget;
class EditGroupWidget;
class Entry;
class EntryView;
class EntrySearcher;
class Group;
class GroupView;
class KeePass1OpenWidget;
@ -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

View File

@ -137,7 +137,7 @@ void SearchWidget::startSearchTimer()
if (!m_searchTimer->isActive()) {
m_searchTimer->stop();
}
m_searchTimer->start(100);
m_searchTimer->start(300);
}
void SearchWidget::startSearch()

View File

@ -34,6 +34,9 @@
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Minimum</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>30</width>
@ -44,6 +47,18 @@
</item>
<item>
<widget class="QLineEdit" name="searchEdit">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>0</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">padding:3px</string>
</property>

View File

@ -58,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();

View File

@ -43,9 +43,7 @@ public:
int numberOfSelectedEntries();
void setFirstEntryActive();
bool isUsernamesHidden() const;
void setUsernamesHidden(bool hide);
bool isPasswordsHidden() const;
void setPasswordsHidden(bool hide);
QByteArray viewState() const;
bool setViewState(const QByteArray& state);
@ -57,6 +55,10 @@ signals:
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;

View File

@ -22,23 +22,32 @@ QTEST_GUILESS_MAIN(TestEntrySearcher)
void TestEntrySearcher::initTestCase()
{
m_groupRoot = new Group();
m_rootGroup = new Group();
}
void TestEntrySearcher::cleanupTestCase()
{
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();
@ -55,15 +64,15 @@ void TestEntrySearcher::testSearch()
group1->setSearchingEnabled(Group::Disable);
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);
Entry* e1 = new Entry();
e1->setNotes("test search term test");
e1->setUsername("test search term test");
e1->setGroup(group1);
Entry* e11 = new Entry();
@ -71,29 +80,37 @@ void TestEntrySearcher::testSearch()
e11->setGroup(group11);
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->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->setPassword("realpass");
e3b->setGroup(group3);
m_searchResult = m_entrySearcher.search("search term", m_groupRoot, Qt::CaseInsensitive);
QCOMPARE(m_searchResult.count(), 2);
m_searchResult = m_entrySearcher.search("search", m_rootGroup);
QCOMPARE(m_searchResult.count(), 5);
m_searchResult = m_entrySearcher.search("search term", group211, Qt::CaseInsensitive);
m_searchResult = m_entrySearcher.search("search term", m_rootGroup);
QCOMPARE(m_searchResult.count(), 3);
m_searchResult = m_entrySearcher.search("search term", group211);
QCOMPARE(m_searchResult.count(), 1);
// Parent group disabled search
m_searchResult = m_entrySearcher.search("search term", group11, Qt::CaseInsensitive);
m_searchResult = m_entrySearcher.search("password:testpass", 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);
}
@ -102,38 +119,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}"));
}

View File

@ -34,9 +34,10 @@ private slots:
void testAndConcatenationInSearch();
void testSearch();
void testAllAttributesAreSearched();
void testSearchTermParser();
private:
Group* m_groupRoot;
Group* m_rootGroup;
EntrySearcher m_entrySearcher;
QList<Entry*> m_searchResult;
};

View File

@ -841,9 +841,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());