mirror of
https://github.com/keepassxreboot/keepassxc.git
synced 2025-08-10 15:30:34 -04:00
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:
parent
4b57fcb563
commit
4b983251cb
14 changed files with 303 additions and 68 deletions
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue