feat: Allow negation of search predicates (#4207)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
closes https://github.com/Chatterino/chatterino2/issues/3998
This commit is contained in:
kornes 2022-12-04 11:34:13 +00:00 committed by GitHub
parent 4fa214a38a
commit b7888749fe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 139 additions and 111 deletions

View file

@ -2,6 +2,7 @@
## Unversioned ## Unversioned
- Minor: Added ability to negate search options by prefixing it with an exclamation mark (e.g. `!badge:mod` to search for messages where the author does not have the moderator badge). (#4207)
- Minor: Search window input will automatically use currently selected text if present. (#4178) - Minor: Search window input will automatically use currently selected text if present. (#4178)
- Minor: Cleared up highlight sound settings (#4194) - Minor: Cleared up highlight sound settings (#4194)
- Bugfix: Fixed highlight sounds not reloading on change properly. (#4194) - Bugfix: Fixed highlight sounds not reloading on change properly. (#4194)

View file

@ -4,20 +4,18 @@
namespace chatterino { namespace chatterino {
AuthorPredicate::AuthorPredicate(const QStringList &authors) AuthorPredicate::AuthorPredicate(const QString &authors, bool negate)
: authors_() : MessagePredicate(negate)
, authors_()
{ {
// Check if any comma-seperated values were passed and transform those // Check if any comma-seperated values were passed and transform those
for (const auto &entry : authors) for (const auto &author : authors.split(',', Qt::SkipEmptyParts))
{ {
for (const auto &author : entry.split(',', Qt::SkipEmptyParts)) this->authors_ << author;
{
this->authors_ << author;
}
} }
} }
bool AuthorPredicate::appliesTo(const Message &message) bool AuthorPredicate::appliesToImpl(const Message &message)
{ {
return authors_.contains(message.displayName, Qt::CaseInsensitive) || return authors_.contains(message.displayName, Qt::CaseInsensitive) ||
authors_.contains(message.loginName, Qt::CaseInsensitive); authors_.contains(message.loginName, Qt::CaseInsensitive);

View file

@ -16,10 +16,12 @@ public:
/** /**
* @brief Create an AuthorPredicate with a list of users to search for. * @brief Create an AuthorPredicate with a list of users to search for.
* *
* @param authors a list of user names that a message should be sent from * @param authors one or more comma-separated user names that a message should be sent from
* @param negate when set, excludes list of user names from results
*/ */
AuthorPredicate(const QStringList &authors); AuthorPredicate(const QString &authors, bool negate);
protected:
/** /**
* @brief Checks whether the message is authored by any of the users passed * @brief Checks whether the message is authored by any of the users passed
* in the constructor. * in the constructor.
@ -28,7 +30,7 @@ public:
* @return true if the message was authored by one of the specified users, * @return true if the message was authored by one of the specified users,
* false otherwise * false otherwise
*/ */
bool appliesTo(const Message &message); bool appliesToImpl(const Message &message) override;
private: private:
/// Holds the user names that will be searched for /// Holds the user names that will be searched for

View file

@ -4,35 +4,33 @@
namespace chatterino { namespace chatterino {
BadgePredicate::BadgePredicate(const QStringList &badges) BadgePredicate::BadgePredicate(const QString &badges, bool negate)
: MessagePredicate(negate)
{ {
// Check if any comma-seperated values were passed and transform those // Check if any comma-seperated values were passed and transform those
for (const auto &entry : badges) for (const auto &badge : badges.split(',', Qt::SkipEmptyParts))
{ {
for (const auto &badge : entry.split(',', Qt::SkipEmptyParts)) // convert short form name of certain badges to formal name
if (badge.compare("mod", Qt::CaseInsensitive) == 0)
{ {
// convert short form name of certain badges to formal name this->badges_ << "moderator";
if (badge.compare("mod", Qt::CaseInsensitive) == 0) }
{ else if (badge.compare("sub", Qt::CaseInsensitive) == 0)
this->badges_ << "moderator"; {
} this->badges_ << "subscriber";
else if (badge.compare("sub", Qt::CaseInsensitive) == 0) }
{ else if (badge.compare("prime", Qt::CaseInsensitive) == 0)
this->badges_ << "subscriber"; {
} this->badges_ << "premium";
else if (badge.compare("prime", Qt::CaseInsensitive) == 0) }
{ else
this->badges_ << "premium"; {
} this->badges_ << badge;
else
{
this->badges_ << badge;
}
} }
} }
} }
bool BadgePredicate::appliesTo(const Message &message) bool BadgePredicate::appliesToImpl(const Message &message)
{ {
for (const Badge &badge : message.badges) for (const Badge &badge : message.badges)
{ {

View file

@ -16,10 +16,12 @@ public:
/** /**
* @brief Create an BadgePredicate with a list of badges to search for. * @brief Create an BadgePredicate with a list of badges to search for.
* *
* @param badges a list of badges that a message should contain * @param badges one or more comma-separated badges that a message should contain
* @param negate when set, excludes list of badges from results
*/ */
BadgePredicate(const QStringList &badges); BadgePredicate(const QString &badges, bool negate);
protected:
/** /**
* @brief Checks whether the message contains any of the badges passed * @brief Checks whether the message contains any of the badges passed
* in the constructor. * in the constructor.
@ -28,7 +30,7 @@ public:
* @return true if the message contains a badge listed in the specified badges, * @return true if the message contains a badge listed in the specified badges,
* false otherwise * false otherwise
*/ */
bool appliesTo(const Message &message) override; bool appliesToImpl(const Message &message) override;
private: private:
/// Holds the badges that will be searched for /// Holds the badges that will be searched for

View file

@ -4,20 +4,18 @@
namespace chatterino { namespace chatterino {
ChannelPredicate::ChannelPredicate(const QStringList &channels) ChannelPredicate::ChannelPredicate(const QString &channels, bool negate)
: channels_() : MessagePredicate(negate)
, channels_()
{ {
// Check if any comma-seperated values were passed and transform those // Check if any comma-seperated values were passed and transform those
for (const auto &entry : channels) for (const auto &channel : channels.split(',', Qt::SkipEmptyParts))
{ {
for (const auto &channel : entry.split(',', Qt::SkipEmptyParts)) this->channels_ << channel;
{
this->channels_ << channel;
}
} }
} }
bool ChannelPredicate::appliesTo(const Message &message) bool ChannelPredicate::appliesToImpl(const Message &message)
{ {
return channels_.contains(message.channelName, Qt::CaseInsensitive); return channels_.contains(message.channelName, Qt::CaseInsensitive);
} }

View file

@ -16,10 +16,12 @@ public:
/** /**
* @brief Create a ChannelPredicate with a list of channels to search for. * @brief Create a ChannelPredicate with a list of channels to search for.
* *
* @param channels a list of channel names that a message should be sent in * @param channels one or more comma-separated channel names that a message should be sent in
* @param negate when set, excludes list of channel names from results
*/ */
ChannelPredicate(const QStringList &channels); ChannelPredicate(const QString &channels, bool negate);
protected:
/** /**
* @brief Checks whether the message was sent in any of the channels passed * @brief Checks whether the message was sent in any of the channels passed
* in the constructor. * in the constructor.
@ -28,7 +30,7 @@ public:
* @return true if the message was sent in one of the specified channels, * @return true if the message was sent in one of the specified channels,
* false otherwise * false otherwise
*/ */
bool appliesTo(const Message &message); bool appliesToImpl(const Message &message) override;
private: private:
/// Holds the channel names that will be searched for /// Holds the channel names that will be searched for

View file

@ -5,11 +5,12 @@
namespace chatterino { namespace chatterino {
LinkPredicate::LinkPredicate() LinkPredicate::LinkPredicate(bool negate)
: MessagePredicate(negate)
{ {
} }
bool LinkPredicate::appliesTo(const Message &message) bool LinkPredicate::appliesToImpl(const Message &message)
{ {
for (const auto &word : message.messageText.split(' ', Qt::SkipEmptyParts)) for (const auto &word : message.messageText.split(' ', Qt::SkipEmptyParts))
{ {

View file

@ -12,15 +12,21 @@ namespace chatterino {
class LinkPredicate : public MessagePredicate class LinkPredicate : public MessagePredicate
{ {
public: public:
LinkPredicate(); /**
* @brief Create an LinkPredicate
*
* @param negate when set, excludes messages containing links from results
*/
LinkPredicate(bool negate);
protected:
/** /**
* @brief Checks whether the message contains a link. * @brief Checks whether the message contains a link.
* *
* @param message the message to check * @param message the message to check
* @return true if the message contains a link, false otherwise * @return true if the message contains a link, false otherwise
*/ */
bool appliesTo(const Message &message); bool appliesToImpl(const Message &message) override;
}; };
} // namespace chatterino } // namespace chatterino

View file

@ -4,8 +4,9 @@
namespace chatterino { namespace chatterino {
MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags) MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags, bool negate)
: flags_() : MessagePredicate(negate)
, flags_()
{ {
// Check if any comma-seperated values were passed and transform those // Check if any comma-seperated values were passed and transform those
for (const auto &flag : flags.split(',', Qt::SkipEmptyParts)) for (const auto &flag : flags.split(',', Qt::SkipEmptyParts))
@ -54,13 +55,15 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags)
} }
} }
bool MessageFlagsPredicate::appliesTo(const Message &message) bool MessageFlagsPredicate::appliesToImpl(const Message &message)
{ {
// Exclude timeout messages from system flag when timeout flag isn't present // Exclude timeout messages from system flag when timeout flag isn't present
if (this->flags_.has(MessageFlag::System) && if (this->flags_.has(MessageFlag::System) &&
!this->flags_.has(MessageFlag::Timeout)) !this->flags_.has(MessageFlag::Timeout))
{
return message.flags.hasAny(flags_) && return message.flags.hasAny(flags_) &&
!message.flags.has(MessageFlag::Timeout); !message.flags.has(MessageFlag::Timeout);
}
return message.flags.hasAny(flags_); return message.flags.hasAny(flags_);
} }

View file

@ -27,9 +27,11 @@ public:
* "system" is used for the "System" flag. * "system" is used for the "System" flag.
* *
* @param flags a string comma seperated list of names for the flags a message should have * @param flags a string comma seperated list of names for the flags a message should have
* @param negate when set, excludes messages containg selected flags from results
*/ */
MessageFlagsPredicate(const QString &flags); MessageFlagsPredicate(const QString &flags, bool negate);
protected:
/** /**
* @brief Checks whether the message has any of the flags passed * @brief Checks whether the message has any of the flags passed
* in the constructor. * in the constructor.
@ -38,7 +40,7 @@ public:
* @return true if the message has at least one of the specified flags, * @return true if the message has at least one of the specified flags,
* false otherwise * false otherwise
*/ */
bool appliesTo(const Message &message); bool appliesToImpl(const Message &message) override;
private: private:
/// Holds the flags that will be searched for /// Holds the flags that will be searched for

View file

@ -11,7 +11,7 @@ namespace chatterino {
* *
* Message predicates define certain features a message can satisfy. * Message predicates define certain features a message can satisfy.
* Features are represented by classes derived from this abstract class. * Features are represented by classes derived from this abstract class.
* A derived class must override `appliesTo` in order to test for the desired * A derived class must override `appliesToImpl` in order to test for the desired
* feature. * feature.
*/ */
class MessagePredicate class MessagePredicate
@ -19,15 +19,43 @@ class MessagePredicate
public: public:
virtual ~MessagePredicate() = default; virtual ~MessagePredicate() = default;
/**
* @brief Checks whether this predicate applies to the passed message
*
* Calls the derived classes `appliedTo` implementation, and respects the `isNegated_` flag
* it's set.
*
* @param message the message to check for this predicate
* @return true if this predicate applies, false otherwise
**/
bool appliesTo(const Message &message)
{
auto result = this->appliesToImpl(message);
if (this->isNegated_)
{
return !result;
}
return result;
}
protected:
explicit MessagePredicate(bool negate)
: isNegated_(negate)
{
}
/** /**
* @brief Checks whether this predicate applies to the passed message. * @brief Checks whether this predicate applies to the passed message.
* *
* Implementations of `appliesTo` should never change the message's content * Implementations of `appliesToImpl` should never change the message's content
* in order to be compatible with other MessagePredicates. * in order to be compatible with other MessagePredicates.
* *
* @param message the message to check for this predicate * @param message the message to check for this predicate
* @return true if this predicate applies, false otherwise * @return true if this predicate applies, false otherwise
*/ */
virtual bool appliesTo(const Message &message) = 0; virtual bool appliesToImpl(const Message &message) = 0;
private:
const bool isNegated_ = false;
}; };
} // namespace chatterino } // namespace chatterino

View file

@ -2,12 +2,13 @@
namespace chatterino { namespace chatterino {
RegexPredicate::RegexPredicate(const QString &regex) RegexPredicate::RegexPredicate(const QString &regex, bool negate)
: regex_(regex, QRegularExpression::CaseInsensitiveOption) : MessagePredicate(negate)
, regex_(regex, QRegularExpression::CaseInsensitiveOption)
{ {
} }
bool RegexPredicate::appliesTo(const Message &message) bool RegexPredicate::appliesToImpl(const Message &message)
{ {
if (!regex_.isValid()) if (!regex_.isValid())
{ {

View file

@ -20,9 +20,11 @@ public:
* The message is being matched case-insensitively. * The message is being matched case-insensitively.
* *
* @param regex the regex to match the message against * @param regex the regex to match the message against
* @param negate when set, excludes messages matching the regex from results
*/ */
RegexPredicate(const QString &regex); RegexPredicate(const QString &regex, bool negate);
protected:
/** /**
* @brief Checks whether the message matches the regex passed in the * @brief Checks whether the message matches the regex passed in the
* constructor * constructor
@ -32,7 +34,7 @@ public:
* @param message the message to check * @param message the message to check
* @return true if the message matches the regex, false otherwise * @return true if the message matches the regex, false otherwise
*/ */
bool appliesTo(const Message &message); bool appliesToImpl(const Message &message) override;
private: private:
/// Holds the regular expression to match the message against /// Holds the regular expression to match the message against

View file

@ -3,11 +3,12 @@
namespace chatterino { namespace chatterino {
SubstringPredicate::SubstringPredicate(const QString &search) SubstringPredicate::SubstringPredicate(const QString &search)
: search_(search) : MessagePredicate(false)
, search_(search)
{ {
} }
bool SubstringPredicate::appliesTo(const Message &message) bool SubstringPredicate::appliesToImpl(const Message &message)
{ {
return message.searchText.contains(this->search_, Qt::CaseInsensitive); return message.searchText.contains(this->search_, Qt::CaseInsensitive);
} }

View file

@ -22,6 +22,7 @@ public:
*/ */
SubstringPredicate(const QString &search); SubstringPredicate(const QString &search);
protected:
/** /**
* @brief Checks whether the message contains the substring passed in the * @brief Checks whether the message contains the substring passed in the
* constructor. * constructor.
@ -31,7 +32,7 @@ public:
* @param message the message to check * @param message the message to check
* @return true if the message contains the substring, false otherwise * @return true if the message contains the substring, false otherwise
*/ */
bool appliesTo(const Message &message); bool appliesToImpl(const Message &message) override;
private: private:
/// Holds the substring to search for in a message's `messageText` /// Holds the substring to search for in a message's `messageText`

View file

@ -4,19 +4,17 @@
namespace chatterino { namespace chatterino {
SubtierPredicate::SubtierPredicate(const QStringList &subtiers) SubtierPredicate::SubtierPredicate(const QString &subtiers, bool negate)
: MessagePredicate(negate)
{ {
// Check if any comma-seperated values were passed and transform those // Check if any comma-seperated values were passed and transform those
for (const auto &entry : subtiers) for (const auto &subtier : subtiers.split(',', Qt::SkipEmptyParts))
{ {
for (const auto &subtier : entry.split(',', Qt::SkipEmptyParts)) this->subtiers_ << subtier;
{
this->subtiers_ << subtier;
}
} }
} }
bool SubtierPredicate::appliesTo(const Message &message) bool SubtierPredicate::appliesToImpl(const Message &message)
{ {
for (const Badge &badge : message.badges) for (const Badge &badge : message.badges)
{ {

View file

@ -16,10 +16,12 @@ public:
/** /**
* @brief Create an SubtierPredicate with a list of subtiers to search for. * @brief Create an SubtierPredicate with a list of subtiers to search for.
* *
* @param subtiers a list of subtiers that a message should contain * @param subtiers one or more comma-separated subtiers that the message should contain
* @param negate when set, excludes messages containing selected subtiers from results
*/ */
SubtierPredicate(const QStringList &subtiers); SubtierPredicate(const QString &subtiers, bool negate);
protected:
/** /**
* @brief Checks whether the message contains any of the subtiers passed * @brief Checks whether the message contains any of the subtiers passed
* in the constructor. * in the constructor.
@ -28,7 +30,7 @@ public:
* @return true if the message contains a subtier listed in the specified subtiers, * @return true if the message contains a subtier listed in the specified subtiers,
* false otherwise * false otherwise
*/ */
bool appliesTo(const Message &message) override; bool appliesToImpl(const Message &message) override;
private: private:
/// Holds the subtiers that will be searched for /// Holds the subtiers that will be searched for

View file

@ -302,55 +302,57 @@ std::vector<std::unique_ptr<MessagePredicate>> SearchPopup::parsePredicates(
// It also ignores whitespaces in values when being surrounded by quotation // It also ignores whitespaces in values when being surrounded by quotation
// marks, to enable inputs like this => regex:"kappa 123" // marks, to enable inputs like this => regex:"kappa 123"
static QRegularExpression predicateRegex( static QRegularExpression predicateRegex(
R"lit((?:(?<name>\w+):(?<value>".+?"|[^\s]+))|[^\s]+?(?=$|\s))lit"); R"lit((?<negation>[!\-])?(?:(?<name>\w+):(?<value>".+?"|[^\s]+))|[^\s]+?(?=$|\s))lit");
static QRegularExpression trimQuotationMarksRegex(R"(^"|"$)"); static QRegularExpression trimQuotationMarksRegex(R"(^"|"$)");
QRegularExpressionMatchIterator it = predicateRegex.globalMatch(input); QRegularExpressionMatchIterator it = predicateRegex.globalMatch(input);
std::vector<std::unique_ptr<MessagePredicate>> predicates; std::vector<std::unique_ptr<MessagePredicate>> predicates;
QStringList authors;
QStringList channels;
QStringList badges;
QStringList subtiers;
while (it.hasNext()) while (it.hasNext())
{ {
QRegularExpressionMatch match = it.next(); QRegularExpressionMatch match = it.next();
QString name = match.captured("name"); QString name = match.captured("name");
bool isNegated = !match.captured("negation").isEmpty();
QString value = match.captured("value"); QString value = match.captured("value");
value.remove(trimQuotationMarksRegex); value.remove(trimQuotationMarksRegex);
// match predicates // match predicates
if (name == "from") if (name == "from")
{ {
authors.append(value); predicates.push_back(
std::make_unique<AuthorPredicate>(value, isNegated));
} }
else if (name == "badge") else if (name == "badge")
{ {
badges.append(value); predicates.push_back(
std::make_unique<BadgePredicate>(value, isNegated));
} }
else if (name == "subtier") else if (name == "subtier")
{ {
subtiers.append(value); predicates.push_back(
std::make_unique<SubtierPredicate>(value, isNegated));
} }
else if (name == "has" && value == "link") else if (name == "has" && value == "link")
{ {
predicates.push_back(std::make_unique<LinkPredicate>()); predicates.push_back(std::make_unique<LinkPredicate>(isNegated));
} }
else if (name == "in") else if (name == "in")
{ {
channels.append(value); predicates.push_back(
std::make_unique<ChannelPredicate>(value, isNegated));
} }
else if (name == "is") else if (name == "is")
{ {
predicates.push_back( predicates.push_back(
std::make_unique<MessageFlagsPredicate>(value)); std::make_unique<MessageFlagsPredicate>(value, isNegated));
} }
else if (name == "regex") else if (name == "regex")
{ {
predicates.push_back(std::make_unique<RegexPredicate>(value)); predicates.push_back(
std::make_unique<RegexPredicate>(value, isNegated));
} }
else else
{ {
@ -359,26 +361,6 @@ std::vector<std::unique_ptr<MessagePredicate>> SearchPopup::parsePredicates(
} }
} }
if (!authors.empty())
{
predicates.push_back(std::make_unique<AuthorPredicate>(authors));
}
if (!channels.empty())
{
predicates.push_back(std::make_unique<ChannelPredicate>(channels));
}
if (!badges.empty())
{
predicates.push_back(std::make_unique<BadgePredicate>(badges));
}
if (!subtiers.empty())
{
predicates.push_back(std::make_unique<SubtierPredicate>(subtiers));
}
return predicates; return predicates;
} }