File indexing completed on 2024-05-05 05:35:44
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.categoryRelevance = qToUnderlying(KRunner::QueryMatch::CategoryRelevance::Lowest); 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.categoryRelevance = qToUnderlying(KRunner::QueryMatch::CategoryRelevance::Highest); 0171 relevance = 1; 0172 } else { 0173 match.categoryRelevance = qToUnderlying(KRunner::QueryMatch::CategoryRelevance::High); 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 } 0226 0227 #include "moc_historyrunnerplugin.cpp"