diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f56b64ad..c5c5a981 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -57,6 +57,7 @@ set(SOURCES utilities/filemanagerutils.cpp utilities/coverutils.cpp utilities/screenutils.cpp + utilities/searchparserutils.cpp engine/enginebase.cpp engine/enginedevice.cpp diff --git a/src/collection/collectionfilterwidget.cpp b/src/collection/collectionfilterwidget.cpp index 15a69c74..c183c108 100644 --- a/src/collection/collectionfilterwidget.cpp +++ b/src/collection/collectionfilterwidget.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("

") + @@ -80,18 +81,26 @@ CollectionFilterWidget::CollectionFilterWidget(QWidget *parent) QString(" ") + QString("") + tr("artist") + - QString(":") + - QString("Strawbs") + - QString(" ") + - tr("searches the collection for all artists that contain the word") + - QString(" Strawbs.") + + QString(":Strawbs ") + + tr("searches the collection for all artists that contain the word %1. ").arg("Strawbs") + + QString("

") + + tr("Search terms for numerical fields can be prefixed with %1 or %2 to refine the search, e.g.: ") + .arg(" =, !=, <, >, <=", ">=") + + QString("") + + tr("rating") + + QString("") + + QString(":>=") + + QString("4") + + QString("

") + tr("Available fields") + QString(": ") + - "" + + QString("") + + QString("") + available_fields + QString(".") + - QString("

")); + QString("

") + ); QObject::connect(ui_->search_field, &QSearchField::returnPressed, this, &CollectionFilterWidget::ReturnPressed); QObject::connect(filter_delay_, &QTimer::timeout, this, &CollectionFilterWidget::FilterDelayTimeout); diff --git a/src/collection/collectionquery.cpp b/src/collection/collectionquery.cpp index ab3c4d85..8530a232 100644 --- a/src/collection/collectionquery.cpp +++ b/src/collection/collectionquery.cpp @@ -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?)"); + 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 diff --git a/src/collection/collectionquery.h b/src/collection/collectionquery.h index a2f3d9cb..0121bdd8 100644 --- a/src/collection/collectionquery.h +++ b/src/collection/collectionquery.h @@ -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; } diff --git a/src/core/song.cpp b/src/core/song.cpp index 205ee509..005876fa 100644 --- a/src/core/song.cpp +++ b/src/core/song.cpp @@ -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" diff --git a/src/core/song.h b/src/core/song.h index 39776d32..76b57118 100644 --- a/src/core/song.h +++ b/src/core/song.h @@ -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; diff --git a/src/playlist/playlistcontainer.cpp b/src/playlist/playlistcontainer.cpp index a6299c75..e7b4cf88 100644 --- a/src/playlist/playlistcontainer.cpp +++ b/src/playlist/playlistcontainer.cpp @@ -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("

") + 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("") + tr("artist") + QString(":Strawbs ") + - 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("

") + tr("Search terms for numerical fields can be prefixed with %1 or %2 to refine the search, e.g.: ") diff --git a/src/playlist/playlistfilter.h b/src/playlist/playlistfilter.h index 1191345a..30f68e67 100644 --- a/src/playlist/playlistfilter.h +++ b/src/playlist/playlistfilter.h @@ -51,8 +51,7 @@ class PlaylistFilter : public QSortFilterProxyModel { void SetFilterText(const QString &filter_text); QString filter_text() const { return filter_text_; } - - QMap column_names_; + QMap 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 column_names_; QSet numerical_columns_; QString filter_text_; }; diff --git a/src/playlist/playlistfilterparser.cpp b/src/playlist/playlistfilterparser.cpp index 591ca1f6..834e7eab 100644 --- a/src/playlist/playlistfilterparser.cpp +++ b/src/playlist/playlistfilterparser.cpp @@ -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; - -} diff --git a/src/playlist/playlistfilterparser.h b/src/playlist/playlistfilterparser.h index 46d75e72..f32e34fa 100644 --- a/src/playlist/playlistfilterparser.h +++ b/src/playlist/playlistfilterparser.h @@ -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_; diff --git a/src/utilities/searchparserutils.cpp b/src/utilities/searchparserutils.cpp new file mode 100644 index 00000000..1380ac9a --- /dev/null +++ b/src/utilities/searchparserutils.cpp @@ -0,0 +1,112 @@ +/* + * Strawberry Music Player + * Copyright 2019-2023, Jonas Kvinge + * Copyright 2023, Daniel Ostertag + * + * 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 . + * + */ + +#include + +#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 diff --git a/src/utilities/searchparserutils.h b/src/utilities/searchparserutils.h new file mode 100644 index 00000000..dca6fad2 --- /dev/null +++ b/src/utilities/searchparserutils.h @@ -0,0 +1,33 @@ +/* + * Strawberry Music Player + * Copyright 2019-2023, Jonas Kvinge + * Copyright 2023, Daniel Ostertag + * + * 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 . + * + */ + +#ifndef SEARCHPARSERUTILS_H +#define SEARCHPARSERUTILS_H + +#include + +namespace Utilities { + +int ParseSearchTime(const QString &time_str); +float ParseSearchRating(const QString &rating_str); + +} // namespace Utilities + +#endif // SEARCHPARSERUTILS_H