mirror of
https://github.com/strawberrymusicplayer/strawberry
synced 2025-02-02 02:26:44 +01:00
Add filtering of numerical cols to collection
CollectionFilterWidget: Updated the tooltip, to reflect the changes. CollectionQuery: Add parsing for SQL operators and insert right SQL "where" searches. Song: Add list of numerical columns playlistfilterparser.cpp/FilterParser: move time and rating parsing functions to new file: searchparserutils.cpp: Contains common code used to parse search terms in playlist and collection filters.
This commit is contained in:
parent
82a8a890de
commit
7aa7cdf6f3
@ -57,6 +57,7 @@ set(SOURCES
|
||||
utilities/filemanagerutils.cpp
|
||||
utilities/coverutils.cpp
|
||||
utilities/screenutils.cpp
|
||||
utilities/searchparserutils.cpp
|
||||
|
||||
engine/enginebase.cpp
|
||||
engine/enginedevice.cpp
|
||||
|
@ -73,6 +73,7 @@ CollectionFilterWidget::CollectionFilterWidget(QWidget *parent)
|
||||
ui_->setupUi(this);
|
||||
|
||||
QString available_fields = Song::kFtsColumns.join(", ").replace(QRegularExpression("\\bfts"), "");
|
||||
available_fields += QString(", ") + Song::kNumericalColumns.join(", ");
|
||||
|
||||
ui_->search_field->setToolTip(
|
||||
QString("<html><head/><body><p>") +
|
||||
@ -80,18 +81,26 @@ CollectionFilterWidget::CollectionFilterWidget(QWidget *parent)
|
||||
QString(" ") +
|
||||
QString("<span style=\"font-weight:600;\">") +
|
||||
tr("artist") +
|
||||
QString(":") +
|
||||
QString("</span><span style=\"font-style:italic;\">Strawbs</span>") +
|
||||
QString(" ") +
|
||||
tr("searches the collection for all artists that contain the word") +
|
||||
QString(" Strawbs.") +
|
||||
QString(":</span><span style=\"font-style:italic;\">Strawbs</span> ") +
|
||||
tr("searches the collection for all artists that contain the word %1. ").arg("Strawbs") +
|
||||
QString("</p><p>") +
|
||||
tr("Search terms for numerical fields can be prefixed with %1 or %2 to refine the search, e.g.: ")
|
||||
.arg(" =, !=, <, >, <=", ">=") +
|
||||
QString("<span style=\"font-weight:600;\">") +
|
||||
tr("rating") +
|
||||
QString("</span>") +
|
||||
QString(":>=") +
|
||||
QString("<span style=\"font-weight:italic;\">4</span>") +
|
||||
|
||||
QString("</p><p><span style=\"font-weight:600;\">") +
|
||||
tr("Available fields") +
|
||||
QString(": ") +
|
||||
"</span><span style=\"font-style:italic;\">" +
|
||||
QString("</span>") +
|
||||
QString("<span style=\"font-style:italic;\">") +
|
||||
available_fields +
|
||||
QString("</span>.") +
|
||||
QString("</p></body></html>"));
|
||||
QString("</p></body></html>")
|
||||
);
|
||||
|
||||
QObject::connect(ui_->search_field, &QSearchField::returnPressed, this, &CollectionFilterWidget::ReturnPressed);
|
||||
QObject::connect(filter_delay_, &QTimer::timeout, this, &CollectionFilterWidget::FilterDelayTimeout);
|
||||
|
@ -36,6 +36,7 @@
|
||||
|
||||
#include "collectionquery.h"
|
||||
#include "collectionfilteroptions.h"
|
||||
#include "utilities/searchparserutils.h"
|
||||
|
||||
CollectionQuery::CollectionQuery(const QSqlDatabase &db, const QString &songs_table, const QString &fts_table, const CollectionFilterOptions &filter_options)
|
||||
: QSqlQuery(db),
|
||||
@ -78,6 +79,29 @@ CollectionQuery::CollectionQuery(const QSqlDatabase &db, const QString &songs_ta
|
||||
query += "fts" + columntoken + "\"" + subtoken + "\"*";
|
||||
}
|
||||
}
|
||||
else if (Song::kNumericalColumns.contains(token.section(':', 0, 0), Qt::CaseInsensitive)) {
|
||||
// Account for multiple colons.
|
||||
QString columntoken = token.section(':', 0, 0);
|
||||
QString subtoken = token.section(':', 1, -1);
|
||||
subtoken = subtoken.trimmed();
|
||||
if (!subtoken.isEmpty()) {
|
||||
QString comparator = RemoveSqlOperator(subtoken);
|
||||
if (columntoken.compare("rating", Qt::CaseInsensitive) == 0) {
|
||||
subtoken.replace(":", " ");
|
||||
AddWhereRating(subtoken, comparator);
|
||||
}
|
||||
else if (columntoken.compare("length", Qt::CaseInsensitive) == 0) {
|
||||
// time is saved in nanoseconds, so add 9 0's
|
||||
QString parsedTime = QString::number(Utilities::ParseSearchTime(subtoken)) + "000000000";
|
||||
AddWhere(columntoken, parsedTime, comparator);
|
||||
}
|
||||
else {
|
||||
subtoken.replace(":", " ");
|
||||
AddWhere(columntoken, subtoken, comparator);
|
||||
}
|
||||
}
|
||||
}
|
||||
// not a valid filter, remove
|
||||
else {
|
||||
token.replace(":", " ");
|
||||
token = token.trimmed();
|
||||
@ -118,6 +142,23 @@ CollectionQuery::CollectionQuery(const QSqlDatabase &db, const QString &songs_ta
|
||||
|
||||
}
|
||||
|
||||
QString CollectionQuery::RemoveSqlOperator(QString &token) {
|
||||
|
||||
QString op = "=";
|
||||
static QRegularExpression rxOp("^(=|<[>=]?|>=?|!=)");
|
||||
QRegularExpressionMatch match = rxOp.match(token);
|
||||
if (match.hasMatch()) {
|
||||
op = match.captured(0);
|
||||
}
|
||||
token.remove(rxOp);
|
||||
|
||||
if (op == "!=") {
|
||||
op = "<>";
|
||||
}
|
||||
return op;
|
||||
|
||||
}
|
||||
|
||||
void CollectionQuery::AddWhere(const QString &column, const QVariant &value, const QString &op) {
|
||||
|
||||
// Ignore 'literal' for IN
|
||||
@ -167,6 +208,37 @@ void CollectionQuery::AddWhereArtist(const QVariant &value) {
|
||||
|
||||
}
|
||||
|
||||
void CollectionQuery::AddWhereRating(const QVariant &value, const QString &op) {
|
||||
|
||||
float parsed_rating = Utilities::ParseSearchRating(value.toString());
|
||||
|
||||
// You can't query the database for a float, due to float precision errors,
|
||||
// So we have to use a certain tolerance, so that the searched value is definetly included.
|
||||
const float tolerance = 0.001;
|
||||
if (op == "<") {
|
||||
AddWhere("rating", parsed_rating-tolerance, "<");
|
||||
}
|
||||
else if (op == ">") {
|
||||
AddWhere("rating", parsed_rating+tolerance, ">");
|
||||
}
|
||||
else if (op == "<=") {
|
||||
AddWhere("rating", parsed_rating+tolerance, "<=");
|
||||
}
|
||||
else if (op == ">=") {
|
||||
AddWhere("rating", parsed_rating-tolerance, ">=");
|
||||
}
|
||||
else if (op == "<>") {
|
||||
where_clauses_ << QString("(rating<? OR rating>?)");
|
||||
bound_values_ << parsed_rating - tolerance;
|
||||
bound_values_ << parsed_rating + tolerance;
|
||||
}
|
||||
else /* (op == "=") */ {
|
||||
AddWhere("rating", parsed_rating+tolerance, "<");
|
||||
AddWhere("rating", parsed_rating-tolerance, ">");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CollectionQuery::AddCompilationRequirement(const bool compilation) {
|
||||
// The unary + is added to prevent sqlite from using the index idx_comp_artist.
|
||||
// When joining with fts, sqlite 3.8 has a tendency to use this index and thereby nesting the tables in an order which gives very poor performance
|
||||
|
@ -61,10 +61,15 @@ class CollectionQuery : public QSqlQuery {
|
||||
void SetOrderBy(const QString &order_by) { order_by_ = order_by; }
|
||||
|
||||
void SetWhereClauses(const QStringList &where_clauses) { where_clauses_ = where_clauses; }
|
||||
|
||||
// Removes = < > <= >= <> from the beginning of the input string and returns the operator
|
||||
// If the input String has no operator, returns "="
|
||||
QString RemoveSqlOperator(QString &token);
|
||||
// Adds a fragment of WHERE clause. When executed, this Query will connect all the fragments with AND operator.
|
||||
// Please note that IN operator expects a QStringList as value.
|
||||
void AddWhere(const QString &column, const QVariant &value, const QString &op = "=");
|
||||
void AddWhereArtist(const QVariant &value);
|
||||
void AddWhereRating(const QVariant &value, const QString &op = "=");
|
||||
|
||||
void SetBoundValues(const QVariantList &bound_values) { bound_values_ = bound_values; }
|
||||
void SetDuplicatesOnly(const bool duplicates_only) { duplicates_only_ = duplicates_only; }
|
||||
|
@ -142,6 +142,17 @@ const QString Song::kColumnSpec = Song::kColumns.join(", ");
|
||||
const QString Song::kBindSpec = Utilities::Prepend(":", Song::kColumns).join(", ");
|
||||
const QString Song::kUpdateSpec = Utilities::Updateify(Song::kColumns).join(", ");
|
||||
|
||||
// used to indicate, what columns can be filtered numerically. Used by the CollectionQuery.
|
||||
const QStringList Song::kNumericalColumns = QStringList() << "year"
|
||||
<< "length"
|
||||
<< "samplerate"
|
||||
<< "bitdepth"
|
||||
<< "bitrate"
|
||||
<< "rating"
|
||||
<< "playcount"
|
||||
<< "skipcount";
|
||||
|
||||
|
||||
const QStringList Song::kFtsColumns = QStringList() << "ftstitle"
|
||||
<< "ftsalbum"
|
||||
<< "ftsartist"
|
||||
|
@ -116,6 +116,8 @@ class Song {
|
||||
static const QString kBindSpec;
|
||||
static const QString kUpdateSpec;
|
||||
|
||||
static const QStringList kNumericalColumns;
|
||||
|
||||
static const QStringList kFtsColumns;
|
||||
static const QString kFtsColumnSpec;
|
||||
static const QString kFtsBindSpec;
|
||||
|
@ -123,7 +123,7 @@ PlaylistContainer::PlaylistContainer(QWidget *parent)
|
||||
QObject::connect(ui_->playlist, &PlaylistView::FocusOnFilterSignal, this, &PlaylistContainer::FocusOnFilter);
|
||||
ui_->search_field->installEventFilter(this);
|
||||
|
||||
QString available_fields = PlaylistFilter().column_names_.keys().join(", ");
|
||||
QString available_fields = PlaylistFilter().column_names().keys().join(", ");
|
||||
ui_->search_field->setToolTip(
|
||||
QString("<html><head/><body><p>") +
|
||||
tr("Prefix a search term with a field name to limit the search to that field, e.g.:") +
|
||||
@ -131,7 +131,7 @@ PlaylistContainer::PlaylistContainer(QWidget *parent)
|
||||
QString("<span style=\"font-weight:600;\">") +
|
||||
tr("artist") +
|
||||
QString(":</span><span style=\"font-style:italic;\">Strawbs</span> ") +
|
||||
tr("searches the collection for all artists that contain the word %1. ").arg("Strawbs") +
|
||||
tr("searches the playlist for all artists that contain the word %1. ").arg("Strawbs") +
|
||||
QString("</p><p>") +
|
||||
|
||||
tr("Search terms for numerical fields can be prefixed with %1 or %2 to refine the search, e.g.: ")
|
||||
|
@ -51,8 +51,7 @@ class PlaylistFilter : public QSortFilterProxyModel {
|
||||
void SetFilterText(const QString &filter_text);
|
||||
|
||||
QString filter_text() const { return filter_text_; }
|
||||
|
||||
QMap<QString, int> column_names_;
|
||||
QMap<QString, int> column_names() const { return column_names_; }
|
||||
|
||||
private:
|
||||
// Mutable because they're modified from filterAcceptsRow() const
|
||||
@ -63,6 +62,7 @@ class PlaylistFilter : public QSortFilterProxyModel {
|
||||
mutable uint query_hash_;
|
||||
#endif
|
||||
|
||||
QMap<QString, int> column_names_;
|
||||
QSet<int> numerical_columns_;
|
||||
QString filter_text_;
|
||||
};
|
||||
|
@ -34,6 +34,7 @@
|
||||
|
||||
#include "playlist.h"
|
||||
#include "playlistfilterparser.h"
|
||||
#include "utilities/searchparserutils.h"
|
||||
|
||||
class SearchTermComparator {
|
||||
public:
|
||||
@ -518,28 +519,28 @@ FilterTree *FilterParser::createSearchTermTreeNode(const QString &col, const QSt
|
||||
|
||||
// Handle the float based Rating Column
|
||||
if (columns_[col] == Playlist::Column_Rating) {
|
||||
float parsedSearch = parseRating(search);
|
||||
float parsed_search = Utilities::ParseSearchRating(search);
|
||||
|
||||
if (prefix == "=") {
|
||||
cmp = new FloatEqComparator(parsedSearch);
|
||||
cmp = new FloatEqComparator(parsed_search);
|
||||
}
|
||||
else if (prefix == "!=" || prefix == "<>") {
|
||||
cmp = new FloatNeComparator(parsedSearch);
|
||||
cmp = new FloatNeComparator(parsed_search);
|
||||
}
|
||||
else if (prefix == ">") {
|
||||
cmp = new FloatGtComparator(parsedSearch);
|
||||
cmp = new FloatGtComparator(parsed_search);
|
||||
}
|
||||
else if (prefix == ">=") {
|
||||
cmp = new FloatGeComparator(parsedSearch);
|
||||
cmp = new FloatGeComparator(parsed_search);
|
||||
}
|
||||
else if (prefix == "<") {
|
||||
cmp = new FloatLtComparator(parsedSearch);
|
||||
cmp = new FloatLtComparator(parsed_search);
|
||||
}
|
||||
else if (prefix == "<=") {
|
||||
cmp = new FloatLeComparator(parsedSearch);
|
||||
cmp = new FloatLeComparator(parsed_search);
|
||||
}
|
||||
else {
|
||||
cmp = new FloatEqComparator(parsedSearch);
|
||||
cmp = new FloatEqComparator(parsed_search);
|
||||
}
|
||||
}
|
||||
else if (prefix == "!=" || prefix == "<>") {
|
||||
@ -549,7 +550,7 @@ FilterTree *FilterParser::createSearchTermTreeNode(const QString &col, const QSt
|
||||
// the length column contains the time in seconds (nanoseconds, actually - the "nano" part is handled by the DropTailComparatorDecorator, though).
|
||||
int search_value = 0;
|
||||
if (columns_[col] == Playlist::Column_Length) {
|
||||
search_value = parseTime(search);
|
||||
search_value = Utilities::ParseSearchTime(search);
|
||||
}
|
||||
else {
|
||||
search_value = search.toInt();
|
||||
@ -604,80 +605,3 @@ FilterTree *FilterParser::createSearchTermTreeNode(const QString &col, const QSt
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Try and parse the string as '[[h:]m:]s' (ignoring all spaces),
|
||||
// and return the number of seconds if it parses correctly.
|
||||
// If not, the original string is returned.
|
||||
// The 'h', 'm' and 's' components can have any length (including 0).
|
||||
//
|
||||
// A few examples:
|
||||
// "::" is parsed to "0"
|
||||
// "1::" is parsed to "3600"
|
||||
// "3:45" is parsed to "225"
|
||||
// "1:165" is parsed to "225"
|
||||
// "225" is parsed to "225" (srsly! ^.^)
|
||||
// "2:3:4:5" is parsed to "2:3:4:5"
|
||||
// "25m" is parsed to "25m"
|
||||
int FilterParser::parseTime(const QString &time_str) {
|
||||
|
||||
int seconds = 0;
|
||||
int accum = 0;
|
||||
int colon_count = 0;
|
||||
for (const QChar &c : time_str) {
|
||||
if (c.isDigit()) {
|
||||
accum = accum * 10 + c.digitValue();
|
||||
}
|
||||
else if (c == ':') {
|
||||
seconds = seconds * 60 + accum;
|
||||
accum = 0;
|
||||
++colon_count;
|
||||
if (colon_count > 2) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
else if (!c.isSpace()) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
seconds = seconds * 60 + accum;
|
||||
|
||||
return seconds;
|
||||
|
||||
}
|
||||
|
||||
// The rating column contains the rating as a float from 0-1 or -1 if unrated.
|
||||
// If the rating is a number from 0-5, map it to 0-1
|
||||
// To use float values directly, the search term can be prefixed with "f" (rating:>f0.2)
|
||||
// If search is 0, or by default, uses -1
|
||||
float FilterParser::parseRating(const QString &rating_str) {
|
||||
|
||||
if (rating_str.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
float rating = -1;
|
||||
bool ok = false;
|
||||
float rating_input = rating_str.toFloat(&ok);
|
||||
// is valid int from 0-5: convert to float
|
||||
if (ok && rating_input >= 0 && rating_input <= 5) {
|
||||
rating = rating_input / 5.0F;
|
||||
}
|
||||
|
||||
// check if the search is a float
|
||||
else if (rating_str.at(0) == 'f') {
|
||||
QString rating_float = rating_str;
|
||||
rating_float = rating_float.remove(0, 1);
|
||||
|
||||
ok = false;
|
||||
rating_float.toFloat(&ok);
|
||||
if (ok) {
|
||||
rating = rating_float.toFloat(&ok);
|
||||
}
|
||||
}
|
||||
// Songs with zero rating have -1 in the DB
|
||||
if (rating == 0) {
|
||||
rating = -1;
|
||||
}
|
||||
|
||||
return rating;
|
||||
|
||||
}
|
||||
|
@ -88,8 +88,6 @@ class FilterParser {
|
||||
FilterTree *parseSearchTerm();
|
||||
|
||||
FilterTree *createSearchTermTreeNode(const QString &col, const QString &prefix, const QString &search) const;
|
||||
static int parseTime(const QString &time_str);
|
||||
static float parseRating(const QString &rating_str);
|
||||
|
||||
QString::const_iterator iter_;
|
||||
QString::const_iterator end_;
|
||||
|
112
src/utilities/searchparserutils.cpp
Normal file
112
src/utilities/searchparserutils.cpp
Normal file
@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2019-2023, Jonas Kvinge <jonas@jkvinge.net>
|
||||
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#include <QString>
|
||||
|
||||
#include "searchparserutils.h"
|
||||
|
||||
namespace Utilities {
|
||||
|
||||
/**
|
||||
* @brief Try and parse the string as '[[h:]m:]s' (ignoring all spaces),
|
||||
* and return the number of seconds if it parses correctly.
|
||||
* If not, the original string is returned.
|
||||
* The 'h', 'm' and 's' components can have any length (including 0).
|
||||
* A few examples:
|
||||
* "::" is parsed to "0"
|
||||
* "1::" is parsed to "3600"
|
||||
* "3:45" is parsed to "225"
|
||||
* "1:165" is parsed to "225"
|
||||
* "225" is parsed to "225" (srsly! ^.^)
|
||||
* "2:3:4:5" is parsed to "2:3:4:5"
|
||||
* "25m" is parsed to "25m"
|
||||
* @param time_str
|
||||
* @return
|
||||
*/
|
||||
int ParseSearchTime(const QString &time_str) {
|
||||
|
||||
int seconds = 0;
|
||||
int accum = 0;
|
||||
int colon_count = 0;
|
||||
for (const QChar &c : time_str) {
|
||||
if (c.isDigit()) {
|
||||
accum = accum * 10 + c.digitValue();
|
||||
}
|
||||
else if (c == ':') {
|
||||
seconds = seconds * 60 + accum;
|
||||
accum = 0;
|
||||
++colon_count;
|
||||
if (colon_count > 2) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
else if (!c.isSpace()) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
seconds = seconds * 60 + accum;
|
||||
|
||||
return seconds;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Parses a rating search term to float.
|
||||
* If the rating is a number from 0-5, map it to 0-1
|
||||
* To use float values directly, the search term can be prefixed with "f" (rating:>f0.2)
|
||||
* If search str is 0, or by default, uses -1
|
||||
* @param rating_str: Rating search 0-5, or "f0.2"
|
||||
* @return float: rating from 0-1 or -1 if not rated.
|
||||
*/
|
||||
float ParseSearchRating(const QString &rating_str) {
|
||||
|
||||
if (rating_str.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
float rating = -1.0F;
|
||||
bool ok = false;
|
||||
float rating_input = rating_str.toFloat(&ok);
|
||||
// is valid int from 0-5: convert to float
|
||||
if (ok && rating_input >= 0 && rating_input <= 5) {
|
||||
rating = rating_input / 5.0F;
|
||||
}
|
||||
|
||||
// check if the search is a float
|
||||
else if (rating_str.at(0) == 'f') {
|
||||
QString rating_float = rating_str;
|
||||
rating_float = rating_float.remove(0, 1);
|
||||
|
||||
ok = false;
|
||||
rating_float.toFloat(&ok);
|
||||
if (ok) {
|
||||
rating = rating_float.toFloat(&ok);
|
||||
}
|
||||
}
|
||||
// Songs with zero rating have -1 in the DB
|
||||
if (rating == 0) {
|
||||
rating = -1;
|
||||
}
|
||||
|
||||
return rating;
|
||||
|
||||
}
|
||||
|
||||
|
||||
} // namespace Utilities
|
33
src/utilities/searchparserutils.h
Normal file
33
src/utilities/searchparserutils.h
Normal file
@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2019-2023, Jonas Kvinge <jonas@jkvinge.net>
|
||||
* Copyright 2023, Daniel Ostertag <daniel.ostertag@dakes.de>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#ifndef SEARCHPARSERUTILS_H
|
||||
#define SEARCHPARSERUTILS_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace Utilities {
|
||||
|
||||
int ParseSearchTime(const QString &time_str);
|
||||
float ParseSearchRating(const QString &rating_str);
|
||||
|
||||
} // namespace Utilities
|
||||
|
||||
#endif // SEARCHPARSERUTILS_H
|
Loading…
x
Reference in New Issue
Block a user