Enhance Tags / Saved Searches

* Rename "Database Tags" to "Searches and Tags"
* Separate searching for all entries and resetting the search
* Support selecting multiple tags to search against
* Fix using escaped quotes in search terms
* Make tag searching more precise
* Support `is:expired-#` to search for entries expiring within # days. Exclude recycled entries from expired search.
* Don't list tags from entries that are recycled
* Force hide tag auto-completion menu when tag editing widget is hidden. On rare occasions the focus out signal is not called when the tag view is hidden (entry edit is closed), this resolves that problem.
* Remove spaces from before and after tags to prevent seemingly duplicate tags from being created.
* Also fix some awkward signal/slot dances that were setup over time with the entry view and preview widget.

Allow changing tags for multiple entries through context menu

* Closes #8277 - show context menu with currently available tags in database and checks those that are set on one or more selected entries. When a tag is selected it is either set or unset on all entries depending on its checked state.

* Add ability to save searches and recall them from the "Searches and Tags" view
* Add ability to remove a tag from all entries from the "Searches and Tags" view
* Cleanup tag handling and widgets
This commit is contained in:
Jonathan White
2022-09-07 19:25:23 -04:00
parent 61f922179b
commit dfee59742f
30 changed files with 573 additions and 115 deletions

View File

@@ -701,8 +701,8 @@ void Database::updateTagList()
// 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 entry : m_rootGroup->entriesRecursive()) {
if (!entry->isRecycled()) {
for (auto tag : entry->tagList()) {
tagSet.insert(tag);
}
@@ -714,6 +714,17 @@ void Database::updateTagList()
emit tagListUpdated();
}
void Database::removeTag(const QString& tag)
{
if (!m_rootGroup) {
return;
}
for (auto entry : m_rootGroup->entriesRecursive()) {
entry->removeTag(tag);
}
}
const QUuid& Database::cipher() const
{
return m_data.cipher;

View File

@@ -129,6 +129,7 @@ public:
const QStringList& commonUsernames() const;
const QStringList& tagList() const;
void removeTag(const QString& tag);
QSharedPointer<const CompositeKey> key() const;
bool setKey(const QSharedPointer<const CompositeKey>& key,

View File

@@ -187,15 +187,12 @@ QString Entry::overrideUrl() const
QString Entry::tags() const
{
return m_data.tags;
return m_data.tags.join(",");
}
QStringList Entry::tagList() const
{
static QRegExp rx("(\\,|\\t|\\;)");
auto taglist = tags().split(rx, QString::SkipEmptyParts);
std::sort(taglist.begin(), taglist.end());
return taglist;
return m_data.tags;
}
const TimeInfo& Entry::timeInfo() const
@@ -654,7 +651,42 @@ void Entry::setOverrideUrl(const QString& url)
void Entry::setTags(const QString& tags)
{
set(m_data.tags, tags);
static QRegExp rx("(\\,|\\t|\\;)");
auto taglist = tags.split(rx, QString::SkipEmptyParts);
// Trim whitespace before/after tag text
for (auto itr = taglist.begin(); itr != taglist.end(); ++itr) {
*itr = itr->trimmed();
}
// Remove duplicates
auto tagSet = QSet<QString>::fromList(taglist);
taglist = tagSet.toList();
// Sort alphabetically
taglist.sort();
set(m_data.tags, taglist);
}
void Entry::addTag(const QString& tag)
{
auto cleanTag = tag.trimmed();
cleanTag.remove(QRegExp("(\\,|\\t|\\;)"));
auto taglist = m_data.tags;
if (!taglist.contains(cleanTag)) {
taglist.append(cleanTag);
taglist.sort();
set(m_data.tags, taglist);
}
}
void Entry::removeTag(const QString& tag)
{
auto cleanTag = tag.trimmed();
cleanTag.remove(QRegExp("(\\,|\\t|\\;)"));
auto taglist = m_data.tags;
if (taglist.removeAll(tag) > 0) {
set(m_data.tags, taglist);
}
}
void Entry::setTimeInfo(const TimeInfo& timeInfo)

View File

@@ -58,7 +58,7 @@ struct EntryData
QString foregroundColor;
QString backgroundColor;
QString overrideUrl;
QString tags;
QStringList tags;
bool autoTypeEnabled;
int autoTypeObfuscation;
QString defaultAutoTypeSequence;
@@ -158,6 +158,9 @@ public:
void setPreviousParentGroup(const Group* group);
void setPreviousParentGroupUuid(const QUuid& uuid);
void addTag(const QString& tag);
void removeTag(const QString& tag);
QList<Entry*> historyItems();
const QList<Entry*>& historyItems() const;
void addHistoryItem(Entry* entry);

View File

@@ -25,8 +25,6 @@
EntrySearcher::EntrySearcher(bool caseSensitive, bool skipProtected)
: m_caseSensitive(caseSensitive)
, m_skipProtected(skipProtected)
, m_termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re")
// Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string
{
}
@@ -197,11 +195,16 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
}
break;
case Field::Tag:
found = term.regex.match(entry->tags()).hasMatch();
found = entry->tagList().indexOf(term.regex) != -1;
break;
case Field::Is:
if (term.word.compare("expired", Qt::CaseInsensitive) == 0) {
found = entry->isExpired();
if (term.word.startsWith("expired", Qt::CaseInsensitive)) {
auto days = 0;
auto parts = term.word.split("-", QString::SkipEmptyParts);
if (parts.length() >= 2) {
days = parts[1].toInt();
}
found = entry->willExpireInDays(days) && !entry->isRecycled();
break;
} else if (term.word.compare("weak", Qt::CaseInsensitive) == 0) {
if (!entry->excludeFromReports() && !entry->password().isEmpty() && !entry->isExpired()) {
@@ -220,8 +223,7 @@ bool EntrySearcher::searchEntryImpl(const Entry* entry)
found = term.regex.match(entry->resolvePlaceholder(entry->title())).hasMatch()
|| term.regex.match(entry->resolvePlaceholder(entry->username())).hasMatch()
|| term.regex.match(entry->resolvePlaceholder(entry->url())).hasMatch()
|| term.regex.match(entry->resolvePlaceholder(entry->tags())).hasMatch()
|| term.regex.match(entry->notes()).hasMatch();
|| entry->tagList().indexOf(term.regex) != -1 || term.regex.match(entry->notes()).hasMatch();
}
// negate the result if exclude:
@@ -246,23 +248,26 @@ void EntrySearcher::parseSearchTerms(const QString& searchString)
{QStringLiteral("notes"), Field::Notes},
{QStringLiteral("pw"), Field::Password},
{QStringLiteral("password"), Field::Password},
{QStringLiteral("title"), Field::Title},
{QStringLiteral("t"), Field::Title},
{QStringLiteral("u"), Field::Username}, // u: stands for username rather than url
{QStringLiteral("title"), Field::Title}, // title before tag to capture t:<word>
{QStringLiteral("username"), Field::Username}, // username before url to capture u:<word>
{QStringLiteral("url"), Field::Url},
{QStringLiteral("username"), Field::Username},
{QStringLiteral("group"), Field::Group},
{QStringLiteral("tag"), Field::Tag},
{QStringLiteral("is"), Field::Is}};
// Group 1 = modifiers, Group 2 = field, Group 3 = quoted string, Group 4 = unquoted string
static QRegularExpression termParser(R"re(([-!*+]+)?(?:(\w*):)?(?:(?=")"((?:[^"\\]|\\.)*)"|([^ ]*))( |$))re");
m_searchTerms.clear();
auto results = m_termParser.globalMatch(searchString);
auto results = termParser.globalMatch(searchString);
while (results.hasNext()) {
auto result = results.next();
SearchTerm term{};
// Quoted string group
term.word = result.captured(3);
// Unescape quotes
term.word.replace("\\\"", "\"");
// If empty, use the unquoted string group
if (term.word.isEmpty()) {

View File

@@ -71,7 +71,6 @@ private:
bool m_caseSensitive;
bool m_skipProtected;
QRegularExpression m_termParser;
QList<SearchTerm> m_searchTerms;
friend class TestEntrySearcher;

View File

@@ -24,6 +24,7 @@
#include <QApplication>
#include <QCryptographicHash>
#include <QJsonDocument>
const int Metadata::DefaultHistoryMaxItems = 10;
const int Metadata::DefaultHistoryMaxSize = 6 * 1024 * 1024;
@@ -487,3 +488,26 @@ void Metadata::setSettingsChanged(const QDateTime& value)
Q_ASSERT(value.timeSpec() == Qt::UTC);
m_settingsChanged = value;
}
void Metadata::addSavedSearch(const QString& name, const QString& searchtext)
{
auto searches = savedSearches();
searches.insert(name, searchtext);
auto json = QJsonDocument::fromVariant(searches);
m_customData->set("KPXC_SavedSearch", json.toJson());
}
void Metadata::deleteSavedSearch(const QString& name)
{
auto searches = savedSearches();
searches.remove(name);
auto json = QJsonDocument::fromVariant(searches);
m_customData->set("KPXC_SavedSearch", json.toJson());
}
QVariantMap Metadata::savedSearches()
{
auto searches = m_customData->value("KPXC_SavedSearch");
auto json = QJsonDocument::fromJson(searches.toUtf8());
return json.toVariant().toMap();
}

View File

@@ -23,6 +23,7 @@
#include <QHash>
#include <QPointer>
#include <QUuid>
#include <QVariantMap>
#include "core/CustomData.h"
#include "core/Global.h"
@@ -150,6 +151,9 @@ public:
void setHistoryMaxItems(int value);
void setHistoryMaxSize(int value);
void setUpdateDatetime(bool value);
void addSavedSearch(const QString& name, const QString& searchtext);
void deleteSavedSearch(const QString& name);
QVariantMap savedSearches();
/*
* Copy all attributes from other except:
* - Group pointers/uuids