diff options
| author | Felix Ernst <[email protected]> | 2025-04-07 21:09:00 +0000 |
|---|---|---|
| committer | Felix Ernst <[email protected]> | 2025-04-07 21:09:00 +0000 |
| commit | 4102ccb80457eea44ea280f0ace2a419602bc34b (patch) | |
| tree | 841e039cf9864276c968a397a2ae75c363199342 /src/search/dolphinquery.cpp | |
| parent | bfc177d3d1bc5a4a241e35d59086e4824e7c0bd3 (diff) | |
Rewrite search integration
This huge commit is a nearly complete rewrite of the Dolphin search
code. It implements most of the improved Dolphin search UI/UX as
designed and discussed in a collaborative effort by Kristen McWilliam,
Jin Liu, Andy Betts, Tagwerk, a few others and me.
See https://invent.kde.org/system/dolphin/-/issues/46.
# Notable changes
- A toggle to change the search tool is provided as most contributors
deemed that useful in
https://invent.kde.org/system/dolphin/-/merge_requests/642#note_985112.
- The default search is changed to filenamesearch for maximum
reliability.
- Removing all search parameters will take users back to the view state
prior to starting a search instead of keeping the search results open.
- The UI for choosing file types or modification dates has been made
more powerful with more granularity and more options.
- Most search parameters can be configured from a popup menu which
gives us extra space for extra clarity.
- Labels and help buttons as well as hyperlinks to settings makes sure
the user always knows why some search parameters are unavailable in
some contexts.
- Chips show important search parameters while the popup is closed.
They allow quickly removing filters.
- The titles of the search and the input field placeholder message
change to make clear whether file names or file contents are searched.
- When the user actively switches the search tool, whether content
should be searched, or whether to search everywhere, this is preserved
for the initial state of the search bar when the user opens it the next
time after restarting Dolphin.
# Architecture
- The new DolphinQuery class is independent of the UI and contains all
search parameters modifiable in Dolphin as easy setters and getters.
- DolphinQuery objects are also used to update the states of every
component in the search UI. There is now a clear separation of UI and
search configuration/DolphinQuery.
- DolphinQuery is responsible for exporting to and importing from
search URLs.
- The search UI always reflects the currently configured DolphinQuery
no matter if the user changed the UI to change the DolphinQuery or
loaded a DolphinQuery/older search URL which then is reflected in the
UI.
- I tried to simplify all classes and their interaction between each
other as much as possible.
- I added some tests
BUG: 386754
CCBUG: 435119
CCBUG: 458761
BUG: 446387
BUG: 470136
CCBUG: 471556
CCBUG: 475439
CCBUG: 477969
BUG: 480001
BUG: 483578
BUG: 488047
BUG: 488845
BUG: 500103
FIXED-IN: 25.08
Diffstat (limited to 'src/search/dolphinquery.cpp')
| -rw-r--r-- | src/search/dolphinquery.cpp | 409 |
1 files changed, 339 insertions, 70 deletions
diff --git a/src/search/dolphinquery.cpp b/src/search/dolphinquery.cpp index ed2a6a766..e23f1f08e 100644 --- a/src/search/dolphinquery.cpp +++ b/src/search/dolphinquery.cpp @@ -1,20 +1,75 @@ /* - * SPDX-FileCopyrightText: 2019 Ismael Asensio <[email protected]> - * - * SPDX-License-Identifier: GPL-2.0-or-later - */ + SPDX-FileCopyrightText: 2019 Ismael Asensio <[email protected]> + SPDX-FileCopyrightText: 2025 Felix Ernst <[email protected]> -#include "dolphinquery.h" + SPDX-License-Identifier: GPL-2.0-or-later +*/ -#include <QRegularExpression> +#include "dolphinquery.h" #include "config-dolphin.h" #if HAVE_BALOO +#include <Baloo/IndexerConfig> #include <Baloo/Query> #endif +#include "dolphinplacesmodelsingleton.h" + +#include <KFileMetaData/TypeInfo> +#include <KLocalizedString> + +#include <QRegularExpression> +#include <QUrlQuery> + +using namespace Search; + +bool Search::isSupportedSearchScheme(const QString &urlScheme) +{ + static const QStringList supportedSchemes = { + QStringLiteral("filenamesearch"), + QStringLiteral("baloosearch"), + QStringLiteral("tags"), + }; + + return supportedSchemes.contains(urlScheme); +} + +bool g_testMode = false; + +bool Search::isIndexingEnabledIn(QUrl directory) +{ + if (g_testMode) { + return true; // For unit-testing, let's pretend everything is indexed correctly. + } + +#if HAVE_BALOO + const Baloo::IndexerConfig searchInfo; + return searchInfo.fileIndexingEnabled() && !directory.isEmpty() && searchInfo.shouldBeIndexed(directory.toLocalFile()); +#else + Q_UNUSED(directory) + return false; +#endif +} + +bool Search::isContentIndexingEnabled() +{ + if (g_testMode) { + return true; // For unit-testing, let's pretend everything is indexed correctly. + } + +#if HAVE_BALOO + return !Baloo::IndexerConfig{}.onlyBasicIndexing(); +#else + return false; +#endif +} namespace { +/** The path to be passed so Baloo searches everywhere. */ +constexpr auto balooSearchEverywherePath = QLatin1String(""); +/** The path to be passed so Filenamesearch searches everywhere. */ +constexpr auto filenamesearchEverywherePath = QLatin1String("file:///"); + #if HAVE_BALOO /** Checks if a given term in the Baloo::Query::searchString() is a special search term * @return: the specific search token of the term, or an empty QString() if none is found @@ -23,6 +78,7 @@ QString searchTermToken(const QString &term) { static const QLatin1String searchTokens[]{QLatin1String("filename:"), QLatin1String("modified>="), + QLatin1String("modified>"), QLatin1String("rating>="), QLatin1String("tag:"), QLatin1String("tag=")}; @@ -69,110 +125,323 @@ QString trimChar(const QString &text, const QLatin1Char aChar) } } -DolphinQuery DolphinQuery::fromSearchUrl(const QUrl &searchUrl) +Search::DolphinQuery::DolphinQuery(const QUrl &url, const QUrl &backupSearchPath) { - DolphinQuery model; - model.m_searchUrl = searchUrl; + if (url.scheme() == QLatin1String("filenamesearch")) { + m_searchTool = SearchTool::Filenamesearch; + const QUrlQuery query(url); + const QString filenamesearchSearchPathString = query.queryItemValue(QStringLiteral("url"), QUrl::FullyDecoded); + const QUrl filenamesearchSearchPathUrl = QUrl::fromUserInput(filenamesearchSearchPathString, QString(), QUrl::AssumeLocalFile); + if (!filenamesearchSearchPathUrl.isValid() || filenamesearchSearchPathString == filenamesearchEverywherePath) { + // The parsed search location is either invalid or matches a string that represents searching "everywhere". + m_searchLocations = SearchLocations::Everywhere; + m_searchPath = backupSearchPath; + } else { + m_searchLocations = SearchLocations::FromHere; + m_searchPath = filenamesearchSearchPathUrl; + } + m_searchTerm = query.queryItemValue(QStringLiteral("search"), QUrl::FullyDecoded); + m_searchThrough = query.queryItemValue(QStringLiteral("checkContent")) == QLatin1String("yes") ? SearchThrough::FileContents : SearchThrough::FileNames; + return; + } + +#if HAVE_BALOO + if (url.scheme() == QLatin1String("baloosearch")) { + m_searchTool = SearchTool::Baloo; + initializeFromBalooQuery(Baloo::Query::fromSearchUrl(url), backupSearchPath); + return; + } +#endif - if (searchUrl.scheme() == QLatin1String("baloosearch")) { - model.parseBalooQuery(); - } else if (searchUrl.scheme() == QLatin1String("tags")) { + if (url.scheme() == QLatin1String("tags")) { +#if HAVE_BALOO + m_searchTool = SearchTool::Baloo; +#endif + m_searchLocations = SearchLocations::Everywhere; + m_searchPath = backupSearchPath; // tags can contain # symbols or slashes within the Url - QString tag = trimChar(searchUrl.toString(QUrl::RemoveScheme), QLatin1Char('/')); - model.m_searchTerms << QStringLiteral("tag:%1").arg(tag); + const auto tag = trimChar(url.toString(QUrl::RemoveScheme), QLatin1Char('/')); + if (!tag.isEmpty()) { + m_requiredTags.append(trimChar(url.toString(QUrl::RemoveScheme), QLatin1Char('/'))); + } + return; } - return model; + m_searchPath = url; + switchToPreferredSearchTool(); } -bool DolphinQuery::supportsScheme(const QString &urlScheme) +QUrl DolphinQuery::toUrl() const { - static const QStringList supportedSchemes = { - QStringLiteral("baloosearch"), - QStringLiteral("tags"), - }; + // The following pre-conditions are sanity checks on this DolphinQuery object. If they fail, the issue is that we ever allowed the DolphinQuery to be in an + // inconsistent state to begin with. This should be fixed by bringing this DolphinQuery object into a reasonable state at the end of the constructors or + // setter methods which caused this impossible-to-fulfill combination of conditions. + Q_ASSERT_X(m_searchLocations == SearchLocations::Everywhere || m_searchPath.isValid(), + "DolphinQuery::toUrl()", + "We are supposed to search in a specific location but we do not know where!"); +#if HAVE_BALOO + Q_ASSERT_X(m_searchLocations == SearchLocations::Everywhere || m_searchTool != SearchTool::Baloo || isIndexingEnabledIn(m_searchPath), + "DolphinQuery::toUrl()", + "We are asking Baloo to search in a location which Baloo is not supposed to have indexed!"); +#endif - return supportedSchemes.contains(urlScheme); -} + QUrl url; -void DolphinQuery::parseBalooQuery() -{ #if HAVE_BALOO - const Baloo::Query query = Baloo::Query::fromSearchUrl(m_searchUrl); + /// Create Baloo search URL + if (m_searchTool == SearchTool::Baloo) { + Baloo::Query query; + if (m_fileType != KFileMetaData::Type::Empty) { + query.addType(KFileMetaData::TypeInfo{m_fileType}.name()); + } - m_includeFolder = query.includeFolder(); + QStringList balooQueryStrings = m_unrecognizedBalooQueryStrings; - const QStringList types = query.types(); - m_fileType = types.isEmpty() ? QString() : types.first(); + if (m_searchThrough == SearchThrough::FileContents) { + balooQueryStrings << m_searchTerm; + } else if (!m_searchTerm.isEmpty()) { + balooQueryStrings << QStringLiteral("filename:\"%1\"").arg(m_searchTerm); + } - QStringList textParts; - QString fileName; + if (m_searchLocations == SearchLocations::FromHere) { + query.setIncludeFolder(m_searchPath.toLocalFile()); + } - const QStringList subTerms = splitOutsideQuotes(query.searchString()); - for (const QString &subTerm : subTerms) { - const QString token = searchTermToken(subTerm); - const QString value = stripQuotes(subTerm.mid(token.length())); + if (m_modifiedSinceDate.isValid()) { + balooQueryStrings << QStringLiteral("modified>=%1").arg(m_modifiedSinceDate.toString(Qt::ISODate)); + } - if (token == QLatin1String("filename:")) { - if (!value.isEmpty()) { - fileName = value; - m_hasFileName = true; + if (m_minimumRating >= 1) { + balooQueryStrings << QStringLiteral("rating>=%1").arg(m_minimumRating); + } + + for (const auto &tag : m_requiredTags) { + if (tag.contains(QLatin1Char(' '))) { + balooQueryStrings << QStringLiteral("tag:\"%1\"").arg(tag); + } else { + balooQueryStrings << QStringLiteral("tag:%1").arg(tag); } - continue; - } else if (!token.isEmpty()) { - m_searchTerms << token + value; - continue; - } else if (subTerm == QLatin1String("AND") && subTerm != subTerms.at(0) && subTerm != subTerms.back()) { - continue; - } else if (!value.isEmpty()) { - textParts << value; - m_hasContentSearch = true; } + + query.setSearchString(balooQueryStrings.join(QLatin1Char(' '))); + + return query.toSearchUrl(QUrl::toPercentEncoding(title())); } +#endif - if (m_hasFileName) { - if (m_hasContentSearch) { - textParts << QStringLiteral("filename:\"%1\"").arg(fileName); - } else { - textParts << fileName; - } + /// Create Filenamsearch search URL + url.setScheme(QStringLiteral("filenamesearch")); + + QUrlQuery qUrlQuery; + qUrlQuery.addQueryItem(QStringLiteral("search"), QUrl::toPercentEncoding(m_searchTerm)); + if (m_searchThrough == SearchThrough::FileContents) { + qUrlQuery.addQueryItem(QStringLiteral("checkContent"), QStringLiteral("yes")); } - m_searchText = textParts.join(QLatin1Char(' ')); -#endif + if (m_searchLocations == SearchLocations::FromHere && m_searchPath.isValid()) { + qUrlQuery.addQueryItem(QStringLiteral("url"), QUrl::toPercentEncoding(m_searchPath.url())); + } else { + // Search in root which is considered searching "everywhere". + qUrlQuery.addQueryItem(QStringLiteral("url"), QUrl::toPercentEncoding(filenamesearchEverywherePath)); + } + qUrlQuery.addQueryItem(QStringLiteral("title"), QUrl::toPercentEncoding(title())); + + url.setQuery(qUrlQuery); + return url; } -QUrl DolphinQuery::searchUrl() const +void DolphinQuery::setSearchLocations(SearchLocations searchLocations) { - return m_searchUrl; + m_searchLocations = searchLocations; + switchToPreferredSearchTool(); } -QString DolphinQuery::text() const +void DolphinQuery::setSearchPath(const QUrl &searchPath) { - return m_searchText; + m_searchPath = searchPath; + switchToPreferredSearchTool(); } -QString DolphinQuery::type() const +void DolphinQuery::setSearchThrough(SearchThrough searchThrough) { - return m_fileType; + m_searchThrough = searchThrough; + switchToPreferredSearchTool(); } -QStringList DolphinQuery::searchTerms() const +void DolphinQuery::switchToPreferredSearchTool() { - return m_searchTerms; + const bool isIndexingEnabledInCurrentSearchLocation = m_searchLocations == SearchLocations::Everywhere || isIndexingEnabledIn(m_searchPath); + const bool searchThroughFileContentsWithoutIndexing = m_searchThrough == SearchThrough::FileContents && !isContentIndexingEnabled(); + if (!isIndexingEnabledInCurrentSearchLocation || searchThroughFileContentsWithoutIndexing) { + m_searchTool = SearchTool::Filenamesearch; + return; + } +#if HAVE_BALOO + // The current search location allows searching with Baloo. We switch to Baloo if this is the saved user preference. + if (SearchSettings::searchTool() == QStringLiteral("Baloo")) { + m_searchTool = SearchTool::Baloo; + } +#endif } -QString DolphinQuery::includeFolder() const +#if HAVE_BALOO +void DolphinQuery::initializeFromBalooQuery(const Baloo::Query &balooQuery, const QUrl &backupSearchPath) { - return m_includeFolder; + const QString balooSearchPathString = balooQuery.includeFolder(); + const QUrl balooSearchPathUrl = QUrl::fromUserInput(balooSearchPathString, QString(), QUrl::AssumeLocalFile); + if (!balooSearchPathUrl.isValid() || balooSearchPathString == balooSearchEverywherePath) { + // The parsed search location is either invalid or matches a string that represents searching "everywhere" i.e. in all indexed locations. + m_searchLocations = SearchLocations::Everywhere; + m_searchPath = backupSearchPath; + } else { + m_searchLocations = SearchLocations::FromHere; + m_searchPath = balooSearchPathUrl; + } + + const QStringList types = balooQuery.types(); + // We currently only allow searching for one file type at once. (Searching for more seems out of scope for Dolphin anyway IMO.) + m_fileType = types.isEmpty() ? KFileMetaData::Type::Empty : KFileMetaData::TypeInfo::fromName(types.first()).type(); + + /// If nothing is requested, we use the default. + std::optional<SearchThrough> requestedToSearchThrough; + const QStringList subTerms = splitOutsideQuotes(balooQuery.searchString()); + for (const QString &subTerm : subTerms) { + const QString token = searchTermToken(subTerm); + const QString value = stripQuotes(subTerm.mid(token.length())); + + if (token == QLatin1String("filename:")) { + // This query is meant to not search in file contents. + if (!value.isEmpty()) { + if (m_searchTerm.isEmpty()) { // Seems like we already received a search term for the content search. We don't overwrite it because the Dolphin + // UI does not support searching for differing strings in content and file name. + m_searchTerm = value; + } + if (!requestedToSearchThrough.has_value()) { // If requested to search thorugh contents, searching file names is already implied. + requestedToSearchThrough = SearchThrough::FileNames; + } + } + continue; + } else if (token.startsWith(QLatin1String("modified>="))) { + m_modifiedSinceDate = QDate::fromString(value, Qt::ISODate); + continue; + } else if (token.startsWith(QLatin1String("modified>"))) { + m_modifiedSinceDate = QDate::fromString(value, Qt::ISODate).addDays(1); + continue; + } else if (token.startsWith(QLatin1String("rating>="))) { + m_minimumRating = value.toInt(); + continue; + } else if (token.startsWith(QLatin1String("tag"))) { + m_requiredTags.append(value); + continue; + } else if (!token.isEmpty()) { + m_unrecognizedBalooQueryStrings << token + value; + continue; + } else if (subTerm == QLatin1String("AND") && subTerm != subTerms.at(0) && subTerm != subTerms.back()) { + continue; + } else if (!value.isEmpty()) { + // An empty token means this is just blank text, which is where the generic search term is located. + if (!m_searchTerm.isEmpty()) { + // Multiple search terms are separated by spaces. + m_searchTerm.append(QLatin1Char{' '}); + } + m_searchTerm.append(value); + requestedToSearchThrough = SearchThrough::FileContents; + } + } + if (requestedToSearchThrough.has_value()) { + m_searchThrough = requestedToSearchThrough.value(); + } } +#endif // HAVE_BALOO -bool DolphinQuery::hasContentSearch() const +QString DolphinQuery::title() const { - return m_hasContentSearch; + if (m_searchLocations == SearchLocations::FromHere) { + QString prettySearchLocation; + KFilePlacesModel *placesModel = DolphinPlacesModelSingleton::instance().placesModel(); + QModelIndex url_index = placesModel->closestItem(m_searchPath); + if (url_index.isValid() && placesModel->url(url_index).matches(m_searchPath, QUrl::StripTrailingSlash)) { + prettySearchLocation = placesModel->text(url_index); + } else { + prettySearchLocation = m_searchPath.fileName(); + } + if (prettySearchLocation.isEmpty()) { + prettySearchLocation = m_searchPath.toString(QUrl::RemoveAuthority); + } + + // A great title clearly identifies a search results page among many tabs, windows, or links in the Places panel. + // Using the search term is phenomenal for this because the user has typed this very specific string with a clear intention, so we definitely want to + // reuse the search term in the title if possible. + if (!m_searchTerm.isEmpty()) { + if (m_searchThrough == SearchThrough::FileNames) { + return i18nc("@title of a search results page. %1 is the search term a user entered, %2 is a folder name", + "Search results for “%1” in %2", + m_searchTerm, + prettySearchLocation); + } + Q_ASSERT(m_searchThrough == SearchThrough::FileContents); + return i18nc("@title of a search results page. %1 is the search term a user entered, %2 is a folder name", + "Files containing “%1” in %2", + m_searchTerm, + prettySearchLocation); + } + if (!m_requiredTags.isEmpty()) { + if (m_requiredTags.count() == 1) { + return i18nc("@title of a search results page. %1 is a tag e.g. 'important'. %2 is a folder name", + "Search items tagged “%1” in %2", + m_requiredTags.constFirst(), + prettySearchLocation); + } + return i18nc("@title of a search results page. %1 and %2 are tags e.g. 'important'. %3 is a folder name", + "Search items tagged “%1” and “%2” in %3", + m_requiredTags.constFirst(), + m_requiredTags.constLast(), + prettySearchLocation); + } + if (m_fileType != KFileMetaData::Type::Empty) { + return i18nc("@title of a search results page for items of a specified type. %1 is a file type e.g. 'Document', 'Folder'. %2 is a folder name", + "%1 search results in %2", + KFileMetaData::TypeInfo{m_fileType}.displayName(), + prettySearchLocation); + } + // Everything else failed so we use a very generic title. + return i18nc("@title of a search results page with items matching pre-defined conditions. %1 is a folder name", + "Search results in %1", + prettySearchLocation); + } + + Q_ASSERT(m_searchLocations == SearchLocations::Everywhere); + // A great title clearly identifies a search results page among many tabs, windows, or links in the Places panel. + // Using the search term is phenomenal for this because the user has typed this very specific string with a clear intention, so we definitely want to reuse + // the search term in the title if possible. + if (!m_searchTerm.isEmpty()) { + if (m_searchThrough == SearchThrough::FileNames) { + return i18nc("@title of a search results page. %1 is the search term a user entered", "Search results for “%1”", m_searchTerm); + } + Q_ASSERT(m_searchThrough == SearchThrough::FileContents); + return i18nc("@title of a search results page. %1 is the search term a user entered", "Files containing “%1”", m_searchTerm); + } + if (!m_requiredTags.isEmpty()) { + if (m_requiredTags.count() == 1) { + return i18nc("@title of a search results page. %1 is a tag e.g. 'important'", "Search items tagged “%1”", m_requiredTags.constFirst()); + } + return i18nc("@title of a search results page. %1 and %2 are tags e.g. 'important'", + "Search items tagged “%1” and “%2”", + m_requiredTags.constFirst(), + m_requiredTags.constLast()); + } + if (m_fileType != KFileMetaData::Type::Empty) { + // i18n: Results page for items of a specified type. %1 is a file type e.g. 'Audio', 'Document', 'Folder', 'Archive'. 'Presentation'. + // If putting such a file type at the start does not work in your language in this context, you might want to translate this liberally with + // something along the lines of 'Search items of type “%1”'. + return i18nc("@title of a search. %1 is file type", "%1 search results", KFileMetaData::TypeInfo{m_fileType}.displayName()); + } + // Everything else failed so we use a very generic title. + return i18nc("@title of a search results page with items matching pre-defined conditions", "Search results"); } -bool DolphinQuery::hasFileName() const +/** For testing Baloo in DolphinQueryTest even if it is not indexing any locations yet. */ +void Search::setTestMode() { - return m_hasFileName; -} + g_testMode = true; +}; |
