|
|
@@ -0,0 +1,708 @@
|
|
|
+/*
|
|
|
+ * Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
|
|
+ *
|
|
|
+ * This program 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 2 of the License, or
|
|
|
+ * (at your option) any later version.
|
|
|
+ *
|
|
|
+ * This program 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.
|
|
|
+ */
|
|
|
+
|
|
|
+#include "unifiedsearchresultslistmodel.h"
|
|
|
+
|
|
|
+#include "account.h"
|
|
|
+#include "accountstate.h"
|
|
|
+#include "guiutility.h"
|
|
|
+#include "folderman.h"
|
|
|
+#include "networkjobs.h"
|
|
|
+
|
|
|
+#include <algorithm>
|
|
|
+
|
|
|
+#include <QAbstractListModel>
|
|
|
+#include <QDesktopServices>
|
|
|
+
|
|
|
+namespace {
|
|
|
+QString imagePlaceholderUrlForProviderId(const QString &providerId)
|
|
|
+{
|
|
|
+ if (providerId.contains(QStringLiteral("message"), Qt::CaseInsensitive)
|
|
|
+ || providerId.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral("qrc:///client/theme/black/wizard-talk.svg");
|
|
|
+ } else if (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral("qrc:///client/theme/black/edit.svg");
|
|
|
+ } else if (providerId.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral("qrc:///client/theme/black/deck.svg");
|
|
|
+ } else if (providerId.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral("qrc:///client/theme/black/calendar.svg");
|
|
|
+ } else if (providerId.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral("qrc:///client/theme/black/email.svg");
|
|
|
+ } else if (providerId.contains(QStringLiteral("comment"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral("qrc:///client/theme/black/comment.svg");
|
|
|
+ }
|
|
|
+
|
|
|
+ return QStringLiteral("qrc:///client/theme/change.svg");
|
|
|
+}
|
|
|
+
|
|
|
+QString localIconPathFromIconPrefix(const QString &iconNameWithPrefix)
|
|
|
+{
|
|
|
+ if (iconNameWithPrefix.contains(QStringLiteral("message"), Qt::CaseInsensitive)
|
|
|
+ || iconNameWithPrefix.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral(":/client/theme/black/wizard-talk.svg");
|
|
|
+ } else if (iconNameWithPrefix.contains(QStringLiteral("folder"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral(":/client/theme/black/folder.svg");
|
|
|
+ } else if (iconNameWithPrefix.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral(":/client/theme/black/deck.svg");
|
|
|
+ } else if (iconNameWithPrefix.contains(QStringLiteral("contacts"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral(":/client/theme/black/wizard-groupware.svg");
|
|
|
+ } else if (iconNameWithPrefix.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral(":/client/theme/black/calendar.svg");
|
|
|
+ } else if (iconNameWithPrefix.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) {
|
|
|
+ return QStringLiteral(":/client/theme/black/email.svg");
|
|
|
+ }
|
|
|
+
|
|
|
+ return QStringLiteral(":/client/theme/change.svg");
|
|
|
+}
|
|
|
+
|
|
|
+QString iconUrlForDefaultIconName(const QString &defaultIconName)
|
|
|
+{
|
|
|
+ const QUrl urlForIcon{defaultIconName};
|
|
|
+
|
|
|
+ if (urlForIcon.isValid() && !urlForIcon.scheme().isEmpty()) {
|
|
|
+ return defaultIconName;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (defaultIconName.startsWith(QStringLiteral("icon-"))) {
|
|
|
+ const auto parts = defaultIconName.split(QLatin1Char('-'));
|
|
|
+
|
|
|
+ if (parts.size() > 1) {
|
|
|
+ const QString iconFilePath = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg");
|
|
|
+
|
|
|
+ if (QFile::exists(iconFilePath)) {
|
|
|
+ return iconFilePath;
|
|
|
+ }
|
|
|
+
|
|
|
+ const QString blackIconFilePath = QStringLiteral(":/client/theme/black/") + parts[1] + QStringLiteral(".svg");
|
|
|
+
|
|
|
+ if (QFile::exists(blackIconFilePath)) {
|
|
|
+ return blackIconFilePath;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const auto iconNameFromIconPrefix = localIconPathFromIconPrefix(defaultIconName);
|
|
|
+
|
|
|
+ if (!iconNameFromIconPrefix.isEmpty()) {
|
|
|
+ return iconNameFromIconPrefix;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return QStringLiteral(":/client/theme/change.svg");
|
|
|
+}
|
|
|
+
|
|
|
+QString generateUrlForThumbnail(const QString &thumbnailUrl, const QUrl &serverUrl)
|
|
|
+{
|
|
|
+ auto serverUrlCopy = serverUrl;
|
|
|
+ auto thumbnailUrlCopy = thumbnailUrl;
|
|
|
+
|
|
|
+ if (thumbnailUrlCopy.startsWith(QLatin1Char('/')) || thumbnailUrlCopy.startsWith(QLatin1Char('\\'))) {
|
|
|
+ // relative image resource URL, just needs some concatenation with current server URL
|
|
|
+ // some icons may contain parameters after (?)
|
|
|
+ const QStringList thumbnailUrlCopySplitted = thumbnailUrlCopy.contains(QLatin1Char('?'))
|
|
|
+ ? thumbnailUrlCopy.split(QLatin1Char('?'), Qt::SkipEmptyParts)
|
|
|
+ : QStringList{thumbnailUrlCopy};
|
|
|
+ Q_ASSERT(!thumbnailUrlCopySplitted.isEmpty());
|
|
|
+ serverUrlCopy.setPath(thumbnailUrlCopySplitted[0]);
|
|
|
+ thumbnailUrlCopy = serverUrlCopy.toString();
|
|
|
+ if (thumbnailUrlCopySplitted.size() > 1) {
|
|
|
+ thumbnailUrlCopy += QLatin1Char('?') + thumbnailUrlCopySplitted[1];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return thumbnailUrlCopy;
|
|
|
+}
|
|
|
+
|
|
|
+QString generateUrlForIcon(const QString &fallackIcon, const QUrl &serverUrl)
|
|
|
+{
|
|
|
+ auto serverUrlCopy = serverUrl;
|
|
|
+
|
|
|
+ auto fallackIconCopy = fallackIcon;
|
|
|
+
|
|
|
+ if (fallackIconCopy.startsWith(QLatin1Char('/')) || fallackIconCopy.startsWith(QLatin1Char('\\'))) {
|
|
|
+ // relative image resource URL, just needs some concatenation with current server URL
|
|
|
+ // some icons may contain parameters after (?)
|
|
|
+ const QStringList fallackIconPathSplitted =
|
|
|
+ fallackIconCopy.contains(QLatin1Char('?')) ? fallackIconCopy.split(QLatin1Char('?')) : QStringList{fallackIconCopy};
|
|
|
+ Q_ASSERT(!fallackIconPathSplitted.isEmpty());
|
|
|
+ serverUrlCopy.setPath(fallackIconPathSplitted[0]);
|
|
|
+ fallackIconCopy = serverUrlCopy.toString();
|
|
|
+ if (fallackIconPathSplitted.size() > 1) {
|
|
|
+ fallackIconCopy += QLatin1Char('?') + fallackIconPathSplitted[1];
|
|
|
+ }
|
|
|
+ } else if (!fallackIconCopy.isEmpty()) {
|
|
|
+ // could be one of names for standard icons (e.g. icon-mail)
|
|
|
+ const auto defaultIconUrl = iconUrlForDefaultIconName(fallackIconCopy);
|
|
|
+ if (!defaultIconUrl.isEmpty()) {
|
|
|
+ fallackIconCopy = defaultIconUrl;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return fallackIconCopy;
|
|
|
+}
|
|
|
+
|
|
|
+QString iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, const QString &fallackIcon, const QUrl &serverUrl)
|
|
|
+{
|
|
|
+ if (thumbnailUrl.isEmpty() && fallackIcon.isEmpty()) {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+
|
|
|
+ if (serverUrl.isEmpty()) {
|
|
|
+ const QStringList listImages = {thumbnailUrl, fallackIcon};
|
|
|
+ return listImages.join(QLatin1Char(';'));
|
|
|
+ }
|
|
|
+
|
|
|
+ const auto urlForThumbnail = generateUrlForThumbnail(thumbnailUrl, serverUrl);
|
|
|
+ const auto urlForFallackIcon = generateUrlForIcon(fallackIcon, serverUrl);
|
|
|
+
|
|
|
+ if (urlForThumbnail.isEmpty() && !urlForFallackIcon.isEmpty()) {
|
|
|
+ return urlForFallackIcon;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!urlForThumbnail.isEmpty() && urlForFallackIcon.isEmpty()) {
|
|
|
+ return urlForThumbnail;
|
|
|
+ }
|
|
|
+
|
|
|
+ const QStringList listImages{urlForThumbnail, urlForFallackIcon};
|
|
|
+ return listImages.join(QLatin1Char(';'));
|
|
|
+}
|
|
|
+
|
|
|
+constexpr int searchTermEditingFinishedSearchStartDelay = 800;
|
|
|
+
|
|
|
+// server-side bug of returning the cursor > 0 and isPaginated == 'true', using '5' as it is done on Android client's end now
|
|
|
+constexpr int minimumEntresNumberToShowLoadMore = 5;
|
|
|
+}
|
|
|
+namespace OCC {
|
|
|
+Q_LOGGING_CATEGORY(lcUnifiedSearch, "nextcloud.gui.unifiedsearch", QtInfoMsg)
|
|
|
+
|
|
|
+UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent)
|
|
|
+ : QAbstractListModel(parent)
|
|
|
+ , _accountState(accountState)
|
|
|
+{
|
|
|
+}
|
|
|
+
|
|
|
+QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const
|
|
|
+{
|
|
|
+ Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid));
|
|
|
+
|
|
|
+ switch (role) {
|
|
|
+ case ProviderNameRole:
|
|
|
+ return _results.at(index.row())._providerName;
|
|
|
+ case ProviderIdRole:
|
|
|
+ return _results.at(index.row())._providerId;
|
|
|
+ case ImagePlaceholderRole:
|
|
|
+ return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId);
|
|
|
+ case IconsRole:
|
|
|
+ return _results.at(index.row())._icons;
|
|
|
+ case TitleRole:
|
|
|
+ return _results.at(index.row())._title;
|
|
|
+ case SublineRole:
|
|
|
+ return _results.at(index.row())._subline;
|
|
|
+ case ResourceUrlRole:
|
|
|
+ return _results.at(index.row())._resourceUrl;
|
|
|
+ case RoundedRole:
|
|
|
+ return _results.at(index.row())._isRounded;
|
|
|
+ case TypeRole:
|
|
|
+ return _results.at(index.row())._type;
|
|
|
+ case TypeAsStringRole:
|
|
|
+ return UnifiedSearchResult::typeAsString(_results.at(index.row())._type);
|
|
|
+ }
|
|
|
+
|
|
|
+ return {};
|
|
|
+}
|
|
|
+
|
|
|
+int UnifiedSearchResultsListModel::rowCount(const QModelIndex &parent) const
|
|
|
+{
|
|
|
+ if (parent.isValid()) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ return _results.size();
|
|
|
+}
|
|
|
+
|
|
|
+QHash<int, QByteArray> UnifiedSearchResultsListModel::roleNames() const
|
|
|
+{
|
|
|
+ auto roles = QAbstractListModel::roleNames();
|
|
|
+ roles[ProviderNameRole] = "providerName";
|
|
|
+ roles[ProviderIdRole] = "providerId";
|
|
|
+ roles[IconsRole] = "icons";
|
|
|
+ roles[ImagePlaceholderRole] = "imagePlaceholder";
|
|
|
+ roles[TitleRole] = "resultTitle";
|
|
|
+ roles[SublineRole] = "subline";
|
|
|
+ roles[ResourceUrlRole] = "resourceUrlRole";
|
|
|
+ roles[TypeRole] = "type";
|
|
|
+ roles[TypeAsStringRole] = "typeAsString";
|
|
|
+ roles[RoundedRole] = "isRounded";
|
|
|
+ return roles;
|
|
|
+}
|
|
|
+
|
|
|
+QString UnifiedSearchResultsListModel::searchTerm() const
|
|
|
+{
|
|
|
+ return _searchTerm;
|
|
|
+}
|
|
|
+
|
|
|
+QString UnifiedSearchResultsListModel::errorString() const
|
|
|
+{
|
|
|
+ return _errorString;
|
|
|
+}
|
|
|
+
|
|
|
+QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const
|
|
|
+{
|
|
|
+ return _currentFetchMoreInProgressProviderId;
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::setSearchTerm(const QString &term)
|
|
|
+{
|
|
|
+ if (term == _searchTerm) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ _searchTerm = term;
|
|
|
+ emit searchTermChanged();
|
|
|
+
|
|
|
+ if (!_errorString.isEmpty()) {
|
|
|
+ _errorString.clear();
|
|
|
+ emit errorStringChanged();
|
|
|
+ }
|
|
|
+
|
|
|
+ disconnectAndClearSearchJobs();
|
|
|
+
|
|
|
+ clearCurrentFetchMoreInProgressProviderId();
|
|
|
+
|
|
|
+ disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
|
|
|
+ &UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
|
|
|
+
|
|
|
+ if (_unifiedSearchTextEditingFinishedTimer.isActive()) {
|
|
|
+ _unifiedSearchTextEditingFinishedTimer.stop();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!_searchTerm.isEmpty()) {
|
|
|
+ _unifiedSearchTextEditingFinishedTimer.setInterval(searchTermEditingFinishedSearchStartDelay);
|
|
|
+ connect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
|
|
|
+ &UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
|
|
|
+ _unifiedSearchTextEditingFinishedTimer.start();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!_results.isEmpty()) {
|
|
|
+ beginResetModel();
|
|
|
+ _results.clear();
|
|
|
+ endResetModel();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+bool UnifiedSearchResultsListModel::isSearchInProgress() const
|
|
|
+{
|
|
|
+ return !_searchJobConnections.isEmpty();
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const
|
|
|
+{
|
|
|
+ const QUrlQuery urlQuery{resourceUrl};
|
|
|
+ const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::ComponentFormattingOption::FullyDecoded);
|
|
|
+ const auto fileName =
|
|
|
+ urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::ComponentFormattingOption::FullyDecoded);
|
|
|
+
|
|
|
+ if (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) {
|
|
|
+ if (!_accountState || !_accountState->account()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const QString relativePath = dir + QLatin1Char('/') + fileName;
|
|
|
+ const auto localFiles =
|
|
|
+ FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account());
|
|
|
+
|
|
|
+ if (!localFiles.isEmpty()) {
|
|
|
+ QDesktopServices::openUrl(localFiles.constFirst());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Utility::openBrowser(resourceUrl);
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId)
|
|
|
+{
|
|
|
+ if (isSearchInProgress() || !_currentFetchMoreInProgressProviderId.isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const auto providerInfo = _providers.value(providerId, {});
|
|
|
+
|
|
|
+ if (!providerInfo._id.isEmpty() && providerInfo._id == providerId && providerInfo._isPaginated) {
|
|
|
+ // Load more items
|
|
|
+ _currentFetchMoreInProgressProviderId = providerId;
|
|
|
+ emit currentFetchMoreInProgressProviderIdChanged();
|
|
|
+ startSearchForProvider(providerId, providerInfo._cursor);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::slotSearchTermEditingFinished()
|
|
|
+{
|
|
|
+ disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
|
|
|
+ &UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
|
|
|
+
|
|
|
+ if (!_accountState || !_accountState->account()) {
|
|
|
+ qCCritical(lcUnifiedSearch) << QString("Account state is invalid. Could not start search!");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (_providers.isEmpty()) {
|
|
|
+ auto job = new JsonApiJob(_accountState->account(), QLatin1String("ocs/v2.php/search/providers"));
|
|
|
+ QObject::connect(job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotFetchProvidersFinished);
|
|
|
+ job->start();
|
|
|
+ } else {
|
|
|
+ startSearch();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::slotFetchProvidersFinished(const QJsonDocument &json, int statusCode)
|
|
|
+{
|
|
|
+ const auto job = qobject_cast<JsonApiJob *>(sender());
|
|
|
+
|
|
|
+ if (!job) {
|
|
|
+ qCCritical(lcUnifiedSearch) << QString("Failed to fetch providers.").arg(_searchTerm);
|
|
|
+ _errorString += tr("Failed to fetch providers.") + QLatin1Char('\n');
|
|
|
+ emit errorStringChanged();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (statusCode != 200) {
|
|
|
+ qCCritical(lcUnifiedSearch) << QString("%1: Failed to fetch search providers for '%2'. Error: %3")
|
|
|
+ .arg(statusCode)
|
|
|
+ .arg(_searchTerm)
|
|
|
+ .arg(job->errorString());
|
|
|
+ _errorString +=
|
|
|
+ tr("Failed to fetch search providers for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString())
|
|
|
+ + QLatin1Char('\n');
|
|
|
+ emit errorStringChanged();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const auto providerList =
|
|
|
+ json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toVariant().toList();
|
|
|
+
|
|
|
+ for (const auto &provider : providerList) {
|
|
|
+ const auto providerMap = provider.toMap();
|
|
|
+ const auto id = providerMap[QStringLiteral("id")].toString();
|
|
|
+ const auto name = providerMap[QStringLiteral("name")].toString();
|
|
|
+ if (!name.isEmpty() && id != QStringLiteral("talk-message-current")) {
|
|
|
+ UnifiedSearchProvider newProvider;
|
|
|
+ newProvider._name = name;
|
|
|
+ newProvider._id = id;
|
|
|
+ newProvider._order = providerMap[QStringLiteral("order")].toInt();
|
|
|
+ _providers.insert(newProvider._id, newProvider);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!_providers.empty()) {
|
|
|
+ startSearch();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::slotSearchForProviderFinished(const QJsonDocument &json, int statusCode)
|
|
|
+{
|
|
|
+ Q_ASSERT(_accountState && _accountState->account());
|
|
|
+
|
|
|
+ const auto job = qobject_cast<JsonApiJob *>(sender());
|
|
|
+
|
|
|
+ if (!job) {
|
|
|
+ qCCritical(lcUnifiedSearch) << QString("Search has failed for '%2'.").arg(_searchTerm);
|
|
|
+ _errorString += tr("Search has failed for '%2'.").arg(_searchTerm) + QLatin1Char('\n');
|
|
|
+ emit errorStringChanged();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const auto providerId = job->property("providerId").toString();
|
|
|
+
|
|
|
+ if (providerId.isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!_searchJobConnections.isEmpty()) {
|
|
|
+ _searchJobConnections.remove(providerId);
|
|
|
+
|
|
|
+ if (_searchJobConnections.isEmpty()) {
|
|
|
+ emit isSearchInProgressChanged();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (providerId == _currentFetchMoreInProgressProviderId) {
|
|
|
+ clearCurrentFetchMoreInProgressProviderId();
|
|
|
+ }
|
|
|
+
|
|
|
+ if (statusCode != 200) {
|
|
|
+ qCCritical(lcUnifiedSearch) << QString("%1: Search has failed for '%2'. Error: %3")
|
|
|
+ .arg(statusCode)
|
|
|
+ .arg(_searchTerm)
|
|
|
+ .arg(job->errorString());
|
|
|
+ _errorString +=
|
|
|
+ tr("Search has failed for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString()) + QLatin1Char('\n');
|
|
|
+ emit errorStringChanged();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const auto data = json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toObject();
|
|
|
+ if (!data.isEmpty()) {
|
|
|
+ parseResultsForProvider(data, providerId, job->property("appendResults").toBool());
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::startSearch()
|
|
|
+{
|
|
|
+ Q_ASSERT(_accountState && _accountState->account());
|
|
|
+
|
|
|
+ disconnectAndClearSearchJobs();
|
|
|
+
|
|
|
+ if (!_accountState || !_accountState->account()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!_results.isEmpty()) {
|
|
|
+ beginResetModel();
|
|
|
+ _results.clear();
|
|
|
+ endResetModel();
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const auto &provider : _providers) {
|
|
|
+ startSearchForProvider(provider._id);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, qint32 cursor)
|
|
|
+{
|
|
|
+ Q_ASSERT(_accountState && _accountState->account());
|
|
|
+
|
|
|
+ if (!_accountState || !_accountState->account()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ auto job = new JsonApiJob(_accountState->account(),
|
|
|
+ QLatin1String("ocs/v2.php/search/providers/%1/search").arg(providerId));
|
|
|
+
|
|
|
+ QUrlQuery params;
|
|
|
+ params.addQueryItem(QStringLiteral("term"), _searchTerm);
|
|
|
+ if (cursor > 0) {
|
|
|
+ params.addQueryItem(QStringLiteral("cursor"), QString::number(cursor));
|
|
|
+ job->setProperty("appendResults", true);
|
|
|
+ }
|
|
|
+ job->setProperty("providerId", providerId);
|
|
|
+ job->addQueryParams(params);
|
|
|
+ const auto wasSearchInProgress = isSearchInProgress();
|
|
|
+ _searchJobConnections.insert(providerId,
|
|
|
+ QObject::connect(
|
|
|
+ job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotSearchForProviderFinished));
|
|
|
+ if (isSearchInProgress() && !wasSearchInProgress) {
|
|
|
+ emit isSearchInProgressChanged();
|
|
|
+ }
|
|
|
+ job->start();
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore)
|
|
|
+{
|
|
|
+ const auto cursor = data.value(QStringLiteral("cursor")).toInt();
|
|
|
+ const auto entries = data.value(QStringLiteral("entries")).toVariant().toList();
|
|
|
+
|
|
|
+ auto &provider = _providers[providerId];
|
|
|
+
|
|
|
+ if (provider._id.isEmpty() && fetchedMore) {
|
|
|
+ _providers.remove(providerId);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (entries.isEmpty()) {
|
|
|
+ // we may have received false pagination information from the server, such as, we expect more
|
|
|
+ // results available via pagination, but, there are no more left, so, we need to stop paginating for
|
|
|
+ // this provider
|
|
|
+ provider._isPaginated = false;
|
|
|
+
|
|
|
+ if (fetchedMore) {
|
|
|
+ removeFetchMoreTrigger(provider._id);
|
|
|
+ }
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ provider._isPaginated = data.value(QStringLiteral("isPaginated")).toBool();
|
|
|
+ provider._cursor = cursor;
|
|
|
+
|
|
|
+ if (provider._pageSize == -1) {
|
|
|
+ provider._pageSize = cursor;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ((provider._pageSize != -1 && entries.size() < provider._pageSize)
|
|
|
+ || entries.size() < minimumEntresNumberToShowLoadMore) {
|
|
|
+ // for some providers we are still getting a non-null cursor and isPaginated true even thought
|
|
|
+ // there are no more results to paginate
|
|
|
+ provider._isPaginated = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ QVector<UnifiedSearchResult> newEntries;
|
|
|
+
|
|
|
+ const auto makeResourceUrl = [](const QString &resourceUrl, const QUrl &accountUrl) {
|
|
|
+ QUrl finalResurceUrl(resourceUrl);
|
|
|
+ if (finalResurceUrl.scheme().isEmpty() && accountUrl.scheme().isEmpty()) {
|
|
|
+ finalResurceUrl = accountUrl;
|
|
|
+ finalResurceUrl.setPath(resourceUrl);
|
|
|
+ }
|
|
|
+ return finalResurceUrl;
|
|
|
+ };
|
|
|
+
|
|
|
+ for (const auto &entry : entries) {
|
|
|
+ const auto entryMap = entry.toMap();
|
|
|
+ if (entryMap.isEmpty()) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ UnifiedSearchResult result;
|
|
|
+ result._providerId = provider._id;
|
|
|
+ result._order = provider._order;
|
|
|
+ result._providerName = provider._name;
|
|
|
+ result._isRounded = entryMap.value(QStringLiteral("rounded")).toBool();
|
|
|
+ result._title = entryMap.value(QStringLiteral("title")).toString();
|
|
|
+ result._subline = entryMap.value(QStringLiteral("subline")).toString();
|
|
|
+
|
|
|
+ const auto resourceUrl = entryMap.value(QStringLiteral("resourceUrl")).toString();
|
|
|
+ const auto accountUrl = (_accountState && _accountState->account()) ? _accountState->account()->url() : QUrl();
|
|
|
+
|
|
|
+ result._resourceUrl = makeResourceUrl(resourceUrl, accountUrl);
|
|
|
+ result._icons = iconsFromThumbnailAndFallbackIcon(entryMap.value(QStringLiteral("thumbnailUrl")).toString(),
|
|
|
+ entryMap.value(QStringLiteral("icon")).toString(), accountUrl);
|
|
|
+
|
|
|
+ newEntries.push_back(result);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (fetchedMore) {
|
|
|
+ appendResultsToProvider(newEntries, provider);
|
|
|
+ } else {
|
|
|
+ appendResults(newEntries, provider);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::appendResults(QVector<UnifiedSearchResult> results, const UnifiedSearchProvider &provider)
|
|
|
+{
|
|
|
+ if (provider._cursor > 0 && provider._isPaginated) {
|
|
|
+ UnifiedSearchResult fetchMoreTrigger;
|
|
|
+ fetchMoreTrigger._providerId = provider._id;
|
|
|
+ fetchMoreTrigger._providerName = provider._name;
|
|
|
+ fetchMoreTrigger._order = provider._order;
|
|
|
+ fetchMoreTrigger._type = UnifiedSearchResult::Type::FetchMoreTrigger;
|
|
|
+ results.push_back(fetchMoreTrigger);
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ if (_results.isEmpty()) {
|
|
|
+ beginInsertRows({}, 0, results.size() - 1);
|
|
|
+ _results = results;
|
|
|
+ endInsertRows();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // insertion is done with sorting (first -> by order, then -> by name)
|
|
|
+ const auto itToInsertTo = std::find_if(std::begin(_results), std::end(_results),
|
|
|
+ [&provider](const UnifiedSearchResult ¤t) {
|
|
|
+ // insert before other results of higher order when possible
|
|
|
+ if (current._order > provider._order) {
|
|
|
+ return true;
|
|
|
+ } else {
|
|
|
+ if (current._order == provider._order) {
|
|
|
+ // insert before results of higher QString value when possible
|
|
|
+ return current._providerName > provider._name;
|
|
|
+ }
|
|
|
+
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const auto first = static_cast<int>(std::distance(std::begin(_results), itToInsertTo));
|
|
|
+ const auto last = first + results.size() - 1;
|
|
|
+
|
|
|
+ beginInsertRows({}, first, last);
|
|
|
+ std::copy(std::begin(results), std::end(results), std::inserter(_results, itToInsertTo));
|
|
|
+ endInsertRows();
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::appendResultsToProvider(const QVector<UnifiedSearchResult> &results, const UnifiedSearchProvider &provider)
|
|
|
+{
|
|
|
+ if (results.isEmpty()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const auto providerId = provider._id;
|
|
|
+ /* we need to find the last result that is not a fetch-more-trigger or category-separator for the current
|
|
|
+ provider */
|
|
|
+ const auto itLastResultForProviderReverse =
|
|
|
+ std::find_if(std::rbegin(_results), std::rend(_results), [&providerId](const UnifiedSearchResult &result) {
|
|
|
+ return result._providerId == providerId && result._type == UnifiedSearchResult::Type::Default;
|
|
|
+ });
|
|
|
+
|
|
|
+ if (itLastResultForProviderReverse != std::rend(_results)) {
|
|
|
+ // #1 Insert rows
|
|
|
+ // convert reverse_iterator to iterator
|
|
|
+ const auto itLastResultForProvider = (itLastResultForProviderReverse + 1).base();
|
|
|
+ const auto first = static_cast<int>(std::distance(std::begin(_results), itLastResultForProvider + 1));
|
|
|
+ const auto last = first + results.size() - 1;
|
|
|
+ beginInsertRows({}, first, last);
|
|
|
+ std::copy(std::begin(results), std::end(results), std::inserter(_results, itLastResultForProvider + 1));
|
|
|
+ endInsertRows();
|
|
|
+
|
|
|
+ // #2 Remove the FetchMoreTrigger item if there are no more results to load for this provider
|
|
|
+ if (!provider._isPaginated) {
|
|
|
+ removeFetchMoreTrigger(providerId);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::removeFetchMoreTrigger(const QString &providerId)
|
|
|
+{
|
|
|
+ const auto itFetchMoreTriggerForProviderReverse = std::find_if(
|
|
|
+ std::rbegin(_results),
|
|
|
+ std::rend(_results),
|
|
|
+ [providerId](const UnifiedSearchResult &result) {
|
|
|
+ return result._providerId == providerId && result._type == UnifiedSearchResult::Type::FetchMoreTrigger;
|
|
|
+ });
|
|
|
+
|
|
|
+ if (itFetchMoreTriggerForProviderReverse != std::rend(_results)) {
|
|
|
+ // convert reverse_iterator to iterator
|
|
|
+ const auto itFetchMoreTriggerForProvider = (itFetchMoreTriggerForProviderReverse + 1).base();
|
|
|
+
|
|
|
+ if (itFetchMoreTriggerForProvider != std::end(_results)
|
|
|
+ && itFetchMoreTriggerForProvider != std::begin(_results)) {
|
|
|
+ const auto eraseIndex = static_cast<int>(std::distance(std::begin(_results), itFetchMoreTriggerForProvider));
|
|
|
+ Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast<int>(_results.size()));
|
|
|
+ beginRemoveRows({}, eraseIndex, eraseIndex);
|
|
|
+ _results.erase(itFetchMoreTriggerForProvider);
|
|
|
+ endRemoveRows();
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::disconnectAndClearSearchJobs()
|
|
|
+{
|
|
|
+ for (const auto &connection : _searchJobConnections) {
|
|
|
+ if (connection) {
|
|
|
+ QObject::disconnect(connection);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!_searchJobConnections.isEmpty()) {
|
|
|
+ _searchJobConnections.clear();
|
|
|
+ emit isSearchInProgressChanged();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+void UnifiedSearchResultsListModel::clearCurrentFetchMoreInProgressProviderId()
|
|
|
+{
|
|
|
+ if (!_currentFetchMoreInProgressProviderId.isEmpty()) {
|
|
|
+ _currentFetchMoreInProgressProviderId.clear();
|
|
|
+ emit currentFetchMoreInProgressProviderIdChanged();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+}
|