Add tags feature

* show the tags in the entry preview
* allow searching by tag
* add a sidebar listing the tags in the database
* filter entries by tag on click
* Introduce a new TagsEdit widget that provides pill aesthetics, fast removal functionality and autocompletion
* add tests for the tags feature
* introduce the "is" tag for searching. Support for weak passwords and expired added.
This commit is contained in:
Xavier Valls
2022-01-23 10:00:48 -05:00
committed by Jonathan White
parent 56a1b465a1
commit 4a21cee98c
33 changed files with 1541 additions and 73 deletions

View File

@@ -117,6 +117,7 @@ static const QHash<Config::ConfigKey, ConfigDirective> configStrings = {
{Config::GUI_ListViewState, {QS("GUI/ListViewState"), Local, {}}},
{Config::GUI_SearchViewState, {QS("GUI/SearchViewState"), Local, {}}},
{Config::GUI_SplitterState, {QS("GUI/SplitterState"), Local, {}}},
{Config::GUI_GroupSplitterState, {QS("GUI/GroupSplitterState"), Local, {}}},
{Config::GUI_PreviewSplitterState, {QS("GUI/PreviewSplitterState"), Local, {}}},
{Config::GUI_AutoTypeSelectDialogSize, {QS("GUI/AutoTypeSelectDialogSize"), Local, QSize(600, 250)}},

View File

@@ -98,6 +98,7 @@ public:
GUI_SearchViewState,
GUI_PreviewSplitterState,
GUI_SplitterState,
GUI_GroupSplitterState,
GUI_AutoTypeSelectDialogSize,
GUI_CheckForUpdatesNextCheck,

View File

@@ -52,7 +52,11 @@ Database::Database()
// other signals
connect(m_metadata, &Metadata::modified, this, &Database::markAsModified);
connect(this, &Database::databaseOpened, this, [this]() { updateCommonUsernames(); });
connect(this, &Database::databaseOpened, this, [this]() {
updateCommonUsernames();
updateTagList();
});
connect(this, &Database::modified, this, [this] { updateTagList(); });
connect(this, &Database::databaseSaved, this, [this]() { updateCommonUsernames(); });
connect(m_fileWatcher, &FileWatcher::fileChanged, this, &Database::databaseFileChanged);
@@ -504,6 +508,7 @@ void Database::releaseData()
m_deletedObjects.clear();
m_commonUsernames.clear();
m_tagList.clear();
}
/**
@@ -700,17 +705,46 @@ void Database::addDeletedObject(const QUuid& uuid)
addDeletedObject(delObj);
}
QList<QString> Database::commonUsernames()
const QStringList& Database::commonUsernames() const
{
return m_commonUsernames;
}
const QStringList& Database::tagList() const
{
return m_tagList;
}
void Database::updateCommonUsernames(int topN)
{
m_commonUsernames.clear();
m_commonUsernames.append(rootGroup()->usernamesRecursive(topN));
}
void Database::updateTagList()
{
m_tagList.clear();
if (!m_rootGroup) {
emit tagListUpdated();
return;
}
// Search groups recursively looking for tags
// Use a set to prevent adding duplicates
QSet<QString> tagSet;
for (const auto group : m_rootGroup->groupsRecursive(true)) {
for (const auto entry : group->entries()) {
for (auto tag : entry->tagList()) {
tagSet.insert(tag);
}
}
}
m_tagList = tagSet.toList();
m_tagList.sort();
emit tagListUpdated();
}
const QUuid& Database::cipher() const
{
return m_data.cipher;

View File

@@ -125,7 +125,8 @@ public:
bool containsDeletedObject(const DeletedObject& uuid) const;
void setDeletedObjects(const QList<DeletedObject>& delObjs);
QList<QString> commonUsernames();
const QStringList& commonUsernames() const;
const QStringList& tagList() const;
QSharedPointer<const CompositeKey> key() const;
bool setKey(const QSharedPointer<const CompositeKey>& key,
@@ -151,6 +152,7 @@ public slots:
void markAsModified();
void markAsClean();
void updateCommonUsernames(int topN = 10);
void updateTagList();
void markNonDataChange();
signals:
@@ -166,6 +168,7 @@ signals:
void databaseSaved();
void databaseDiscarded();
void databaseFileChanged();
void tagListUpdated();
private:
struct DatabaseData
@@ -228,7 +231,8 @@ private:
bool m_hasNonDataChange = false;
QString m_keyError;
QList<QString> m_commonUsernames;
QStringList m_commonUsernames;
QStringList m_tagList;
QUuid m_uuid;
static QHash<QUuid, QPointer<Database>> s_uuidMap;

View File

@@ -190,6 +190,12 @@ QString Entry::tags() const
return m_data.tags;
}
QStringList Entry::tagList() const
{
static QRegExp rx("(\\ |\\,|\\.|\\:|\\t|\\;)");
return tags().split(rx, QString::SkipEmptyParts);
}
const TimeInfo& Entry::timeInfo() const
{
return m_data.timeInfo;
@@ -210,7 +216,7 @@ QString Entry::defaultAutoTypeSequence() const
return m_data.defaultAutoTypeSequence;
}
const QSharedPointer<PasswordHealth>& Entry::passwordHealth()
const QSharedPointer<PasswordHealth> Entry::passwordHealth()
{
if (!m_data.passwordHealth) {
m_data.passwordHealth.reset(new PasswordHealth(resolvePlaceholder(password())));
@@ -218,6 +224,14 @@ const QSharedPointer<PasswordHealth>& Entry::passwordHealth()
return m_data.passwordHealth;
}
const QSharedPointer<PasswordHealth> Entry::passwordHealth() const
{
if (!m_data.passwordHealth) {
return QSharedPointer<PasswordHealth>::create(resolvePlaceholder(password()));
}
return m_data.passwordHealth;
}
bool Entry::excludeFromReports() const
{
return m_data.excludeFromReports

View File

@@ -88,6 +88,7 @@ public:
QString backgroundColor() const;
QString overrideUrl() const;
QString tags() const;
QStringList tagList() const;
const TimeInfo& timeInfo() const;
bool autoTypeEnabled() const;
int autoTypeObfuscation() const;
@@ -113,7 +114,8 @@ public:
QUuid previousParentGroupUuid() const;
int size() const;
QString path() const;
const QSharedPointer<PasswordHealth>& passwordHealth();
const QSharedPointer<PasswordHealth> passwordHealth();
const QSharedPointer<PasswordHealth> passwordHealth() const;
bool excludeFromReports() const;
void setExcludeFromReports(bool state);

View File

@@ -18,6 +18,7 @@
#include "EntrySearcher.h"
#include "PasswordHealth.h"
#include "core/Group.h"
#include "core/Tools.h"
@@ -152,7 +153,7 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
auto hierarchy = entry->group()->hierarchy().join('/').prepend("/");
// By default, empty term matches every entry.
// However when skipping protected fields, we will recject everything instead
// However when skipping protected fields, we will reject everything instead
bool found = !m_skipProtected;
for (const auto& term : m_searchTerms) {
switch (term.field) {
@@ -195,11 +196,31 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
found = term.regex.match(entry->group()->name()).hasMatch();
}
break;
case Field::Tag:
found = term.regex.match(entry->tags()).hasMatch();
break;
case Field::Is:
if (term.word.compare("expired", Qt::CaseInsensitive) == 0) {
found = entry->isExpired();
break;
} else if (term.word.compare("weak", Qt::CaseInsensitive) == 0) {
if (!entry->excludeFromReports() && !entry->password().isEmpty() && !entry->isExpired()) {
const auto quality = entry->passwordHealth()->quality();
if (quality == PasswordHealth::Quality::Bad || quality == PasswordHealth::Quality::Poor
|| quality == PasswordHealth::Quality::Weak) {
found = true;
break;
}
}
}
found = false;
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->resolvePlaceholder(entry->tags())).hasMatch()
|| term.regex.match(entry->notes()).hasMatch();
}
@@ -226,10 +247,13 @@ void EntrySearcher::parseSearchTerms(const QString& searchString)
{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("url"), Field::Url},
{QStringLiteral("username"), Field::Username},
{QStringLiteral("group"), Field::Group}};
{QStringLiteral("group"), Field::Group},
{QStringLiteral("tag"), Field::Tag},
{QStringLiteral("is"), Field::Is}};
m_searchTerms.clear();
auto results = m_termParser.globalMatch(searchString);

View File

@@ -38,7 +38,9 @@ public:
AttributeKV,
Attachment,
AttributeValue,
Group
Group,
Tag,
Is
};
struct SearchTerm