File indexing completed on 2024-04-28 16:51:32

0001 /*
0002     SPDX-FileCopyrightText: 2020 Kai Uwe Broulik <kde@broulik.de>
0003 
0004     SPDX-License-Identifier: GPL-3.0-or-later
0005 */
0006 
0007 #include "historyrunnerplugin.h"
0008 
0009 #include "connection.h"
0010 #include "settings.h"
0011 
0012 #include <QDBusConnection>
0013 #include <QGuiApplication>
0014 #include <QIcon>
0015 #include <QImage>
0016 #include <QJsonArray>
0017 #include <QSet>
0018 #include <QUrl>
0019 #include <QVariant>
0020 
0021 #include <KLocalizedString>
0022 
0023 #include <algorithm>
0024 
0025 static const auto s_idSeparator = QLatin1String("@@@");
0026 
0027 static const auto s_errorNoPermission = QLatin1String("NO_PERMISSION");
0028 static const auto s_idRequestPermission = QLatin1String("REQUEST_PERMISSION");
0029 
0030 HistoryRunnerPlugin::HistoryRunnerPlugin(QObject *parent)
0031     : AbstractKRunnerPlugin(QStringLiteral("/HistoryRunner"), QStringLiteral("historyrunner"), 1, parent)
0032 {
0033 }
0034 
0035 RemoteActions HistoryRunnerPlugin::Actions()
0036 {
0037     return {};
0038 }
0039 
0040 RemoteMatches HistoryRunnerPlugin::Match(const QString &searchTerm)
0041 {
0042     if (searchTerm.length() < 3) {
0043         sendErrorReply(QDBusError::InvalidArgs, QStringLiteral("Search term too short"));
0044         return {};
0045     }
0046 
0047     setDelayedReply(true);
0048 
0049     const bool runQuery = !m_requests.contains(searchTerm);
0050 
0051     // It's a multi-hash, so all requests for identical search terms
0052     // will be replied to at once when the results come in
0053     m_requests.insert(searchTerm, message());
0054 
0055     if (runQuery) {
0056         sendData(QStringLiteral("find"),
0057                  {
0058                      {QStringLiteral("query"), searchTerm},
0059                  });
0060     }
0061 
0062     return {};
0063 }
0064 
0065 void HistoryRunnerPlugin::Run(const QString &id, const QString &actionId)
0066 {
0067     if (!actionId.isEmpty()) {
0068         sendErrorReply(QDBusError::InvalidArgs, QStringLiteral("Unknown action ID"));
0069         return;
0070     }
0071 
0072     if (id == s_idRequestPermission) {
0073         sendData(QStringLiteral("requestPermission"));
0074         return;
0075     }
0076 
0077     const int separatorIdx = id.indexOf(s_idSeparator);
0078     if (separatorIdx <= 0) {
0079         return;
0080     }
0081 
0082     const QString historyId = id.left(separatorIdx);
0083     const QString urlString = id.mid(separatorIdx + s_idSeparator.size());
0084 
0085     // Ideally we'd run the "id" but there's no API to query a history item by id.
0086     // To be future proof, we'll send both, just in case there's an "id" API at some point
0087     sendData(QStringLiteral("run"),
0088              {
0089                  // NOTE Chromium uses ints but Firefox returns ID strings, so don't toInt() this!
0090                  {QStringLiteral("id"), historyId},
0091                  {QStringLiteral("url"), urlString},
0092              });
0093 }
0094 
0095 void HistoryRunnerPlugin::handleData(const QString &event, const QJsonObject &json)
0096 {
0097     if (event == QLatin1String("found")) {
0098         const QString query = json.value(QStringLiteral("query")).toString();
0099         const QString error = json.value(QStringLiteral("error")).toString();
0100 
0101         RemoteMatches matches;
0102 
0103         if (error == s_errorNoPermission) {
0104             RemoteMatch match;
0105             match.id = s_idRequestPermission;
0106             match.type = Plasma::QueryMatch::NoMatch;
0107             match.relevance = 0;
0108             match.text = i18nc("Dummy search result", "Additional permissions are required");
0109             match.iconName = qApp->windowIcon().name();
0110             matches.append(match);
0111         } else {
0112             const QJsonArray results = json.value(QStringLiteral("results")).toArray();
0113 
0114             int maxVisitCount = 0;
0115             int maxTypedCount = 0;
0116 
0117             QSet<QUrl> seenUrls;
0118             for (auto it = results.begin(), end = results.end(); it != end; ++it) {
0119                 const QJsonObject &result = it->toObject();
0120 
0121                 const QString urlString = result.value(QStringLiteral("url")).toString();
0122                 QUrl url(urlString);
0123                 // Skip page anchors but only if they don't look like paths used by old Ajax pages
0124                 const QString urlFragment = url.fragment();
0125                 if (!urlFragment.isEmpty() && !urlFragment.contains(QLatin1Char('/'))) {
0126                     url.setFragment(QString());
0127                 }
0128 
0129                 if (url.scheme() == QLatin1String("blob")) {
0130                     continue;
0131                 }
0132 
0133                 if (seenUrls.contains(url)) {
0134                     continue;
0135                 }
0136                 seenUrls.insert(url);
0137 
0138                 const QString id = result.value(QStringLiteral("id")).toString();
0139                 const QString text = result.value(QStringLiteral("title")).toString();
0140                 const int visitCount = result.value(QStringLiteral("visitCount")).toInt();
0141                 const int typedCount = result.value(QStringLiteral("typedCount")).toInt();
0142                 const QString favIconUrl = result.value(QStringLiteral("favIconUrl")).toString();
0143 
0144                 maxVisitCount = std::max(maxVisitCount, visitCount);
0145                 maxTypedCount = std::max(maxTypedCount, typedCount);
0146 
0147                 RemoteMatch match;
0148                 match.id = id + s_idSeparator + urlString;
0149                 if (!text.isEmpty()) {
0150                     match.text = text;
0151                     match.properties.insert(QStringLiteral("subtext"), url.toDisplayString());
0152                 } else {
0153                     match.text = url.toDisplayString();
0154                 }
0155                 match.iconName = qApp->windowIcon().name();
0156 
0157                 QUrl urlWithoutPassword = url;
0158                 urlWithoutPassword.setPassword({});
0159                 match.properties.insert(QStringLiteral("urls"), QUrl::toStringList(QList<QUrl>{urlWithoutPassword}));
0160 
0161                 const QImage favIcon = imageFromDataUrl(favIconUrl);
0162                 if (!favIcon.isNull()) {
0163                     const RemoteImage remoteImage = serializeImage(favIcon);
0164                     match.properties.insert(QStringLiteral("icon-data"), QVariant::fromValue(remoteImage));
0165                 }
0166 
0167                 qreal relevance = 0;
0168 
0169                 if (text.compare(query, Qt::CaseInsensitive) == 0 || urlString.compare(query, Qt::CaseInsensitive) == 0) {
0170                     match.type = Plasma::QueryMatch::ExactMatch;
0171                     relevance = 1;
0172                 } else {
0173                     match.type = Plasma::QueryMatch::PossibleMatch;
0174 
0175                     if (text.contains(query, Qt::CaseInsensitive)) {
0176                         relevance = 0.7;
0177                         if (text.startsWith(query, Qt::CaseInsensitive)) {
0178                             relevance += 0.05;
0179                         }
0180                     } else if (url.host().contains(query, Qt::CaseInsensitive)) {
0181                         relevance = 0.5;
0182                         if (url.host().startsWith(query, Qt::CaseInsensitive)) {
0183                             relevance += 0.05;
0184                         }
0185                     } else if (url.path().contains(query, Qt::CaseInsensitive)) {
0186                         relevance = 0.3;
0187                         if (url.path().startsWith(query, Qt::CaseInsensitive)) {
0188                             relevance += 0.05;
0189                         }
0190                     }
0191                 }
0192 
0193                 match.relevance = relevance;
0194 
0195                 matches.append(match);
0196             }
0197 
0198             // Now slightly weigh the results also by visited and typed count
0199             // The more visits, the higher the relevance boost, but typing counts even higher than visited
0200             // TODO also take into account lastVisitTime?
0201             for (int i = 0; i < matches.count(); ++i) {
0202                 RemoteMatch &match = matches[i];
0203 
0204                 const QJsonObject result = results.at(i).toObject();
0205                 const int visitCount = result.value(QStringLiteral("visitCount")).toInt();
0206                 const int typedCount = result.value(QStringLiteral("typedCount")).toInt();
0207 
0208                 if (maxVisitCount > 0) {
0209                     const qreal visitBoost = visitCount / static_cast<qreal>(maxVisitCount);
0210                     match.relevance += visitBoost * 0.05;
0211                 }
0212                 if (maxTypedCount > 0) {
0213                     const qreal typedBoost = typedCount / static_cast<qreal>(maxTypedCount);
0214                     match.relevance += typedBoost * 0.1;
0215                 }
0216             }
0217         }
0218 
0219         const auto requests = m_requests.values(query);
0220         m_requests.remove(query); // is there a takeAll?
0221         for (const QDBusMessage &request : requests) {
0222             QDBusConnection::sessionBus().send(request.createReply(QVariant::fromValue(matches)));
0223         }
0224     }
0225 }