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:
Dakes 2023-07-31 13:44:08 +02:00 committed by Jonas Kvinge
parent 82a8a890de
commit 7aa7cdf6f3
12 changed files with 266 additions and 99 deletions

View File

@ -57,6 +57,7 @@ set(SOURCES
utilities/filemanagerutils.cpp
utilities/coverutils.cpp
utilities/screenutils.cpp
utilities/searchparserutils.cpp
engine/enginebase.cpp
engine/enginedevice.cpp

View File

@ -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(" =, !=, &lt;, &gt;, &lt;=", "&gt;=") +
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);

View File

@ -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

View File

@ -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; }

View File

@ -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"

View File

@ -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;

View File

@ -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.: ")

View File

@ -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_;
};

View File

@ -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;
}

View File

@ -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_;

View 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

View 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