File indexing completed on 2024-05-12 05:11:20

0001 /*
0002  * SPDX-FileCopyrightText: 2015 Daniel Vrátil <dvratil@redhat.com>
0003  *
0004  * SPDX-License-Identifier: LGPL-2.1-or-later
0005  *
0006  */
0007 
0008 #include "pimcontactsrunner.h"
0009 #include "akonadi_runner_debug.h"
0010 
0011 #include <KConfigGroup>
0012 #include <KLocalizedString>
0013 
0014 #include <QDesktopServices>
0015 #include <QIcon>
0016 #include <QSharedPointer>
0017 #include <QThread>
0018 
0019 #include <Akonadi/ItemFetchJob>
0020 #include <Akonadi/ItemFetchScope>
0021 #include <Akonadi/Session>
0022 
0023 #include <KContacts/Addressee>
0024 
0025 #include <KSharedConfig>
0026 
0027 #include <KEmailAddress>
0028 
0029 #include "lib/contactcompleter.h"
0030 #include "lib/contactquery.h"
0031 #include "lib/resultiterator.h"
0032 
0033 #include <array>
0034 
0035 Q_DECLARE_METATYPE(KContacts::Addressee *)
0036 
0037 PIMContactsRunner::PIMContactsRunner(QObject *parent, const KPluginMetaData &metaData)
0038     : AbstractRunner(parent, metaData)
0039 {
0040     setObjectName(QLatin1StringView("PIMContactsRunner"));
0041     // reloadConfiguration() called by default init() implementation
0042 }
0043 
0044 PIMContactsRunner::~PIMContactsRunner() = default;
0045 
0046 void PIMContactsRunner::reloadConfiguration()
0047 {
0048     mQueryAutocompleter = config().readEntry(QStringLiteral("queryAutocompleter"), true);
0049 }
0050 
0051 void PIMContactsRunner::match(RunnerContext &context)
0052 {
0053     const QString queryString = context.query();
0054     if (queryString.size() < 3) {
0055         return;
0056     }
0057     mListEmails.clear();
0058 
0059     queryContacts(context, queryString);
0060 
0061     qCDebug(AKONADI_KRUNNER_LOG) << this << "MATCH: queryAutocompleter =" << mQueryAutocompleter;
0062     if (mQueryAutocompleter) {
0063         queryAutocompleter(context, queryString);
0064     }
0065 }
0066 
0067 void PIMContactsRunner::queryContacts(RunnerContext &context, const QString &queryString)
0068 {
0069     Akonadi::Search::PIM::ContactQuery query;
0070     query.matchName(queryString);
0071     query.matchEmail(queryString);
0072     query.setMatchCriteria(Akonadi::Search::PIM::ContactQuery::StartsWithMatch);
0073     // Does not make sense to list more than 50 contacts on broad search terms
0074     query.setLimit(50);
0075 
0076     // Accumulate the results so that we can fetch all in single Akonadi request
0077     Akonadi::Search::PIM::ResultIterator iter = query.exec();
0078     QList<Akonadi::Item::Id> results;
0079     while (iter.next()) {
0080         results.push_back(iter.id());
0081     }
0082 
0083     qCDebug(AKONADI_KRUNNER_LOG) << "Query:" << queryString << ", results:" << results.count();
0084 
0085     if (results.isEmpty()) {
0086         return;
0087     }
0088 
0089     // There can be multiple queries running at the same time, make sure we have
0090     // a separate Session for each, otherwise things might explode
0091     QScopedPointer<Akonadi::Session, QScopedPointerDeleteLater> session(
0092         new Akonadi::Session("PIIMContactRunner-" + QByteArray::number((qlonglong)QThread::currentThread())));
0093     auto fetch = new Akonadi::ItemFetchJob(results, session.data());
0094     Akonadi::ItemFetchScope &scope = fetch->fetchScope();
0095     scope.fetchFullPayload(true);
0096     scope.setFetchRemoteIdentification(false);
0097     scope.setAncestorRetrieval(Akonadi::ItemFetchScope::None);
0098 
0099     if (!fetch->exec()) {
0100         qCWarning(AKONADI_KRUNNER_LOG) << "Error while fetching contacts:" << fetch->errorString();
0101         return;
0102     }
0103 
0104     const auto items = fetch->items();
0105     for (const Akonadi::Item &item : items) {
0106         KContacts::Addressee contact;
0107         try {
0108             contact = item.payload<KContacts::Addressee>();
0109         } catch (const Akonadi::Exception &e) {
0110             qCDebug(AKONADI_KRUNNER_LOG) << "Corrupted index? Index referrers to an Item without contact";
0111             // Error?
0112             continue;
0113         }
0114 
0115         if (contact.isEmpty()) {
0116             qCDebug(AKONADI_KRUNNER_LOG) << "Corrupted index? Index refers to an Item with an empty contact";
0117             continue;
0118         }
0119 
0120         const QStringList emails = contact.emails();
0121         if (emails.isEmpty()) {
0122             // No email, don't show the contact
0123             qCDebug(AKONADI_KRUNNER_LOG) << "Skipping" << contact.uid() << ", because it has no emails";
0124             continue;
0125         }
0126 
0127         QueryMatch match(this);
0128         match.setMatchCategory(i18n("Contacts"));
0129         match.setRelevance(0.75); // 0.75 is used by most runners, we don't
0130 
0131         const KContacts::Picture photo = contact.photo();
0132         if (!photo.isEmpty()) {
0133             const QImage img = photo.data().scaled(16, 16, Qt::KeepAspectRatio);
0134             match.setIcon(QIcon(QPixmap::fromImage(img)));
0135         } else {
0136             // The icon should be cached by Qt or FrameworkIntegration
0137             match.setIcon(QIcon::fromTheme(QStringLiteral("user-identity")));
0138         }
0139 
0140         QString matchedEmail;
0141 
0142         QString name = contact.formattedName();
0143         if (name.isEmpty()) {
0144             name = contact.assembledName();
0145         }
0146 
0147         // We got perfect match by name
0148         if (name == queryString) {
0149             match.setCategoryRelevance(QueryMatch::CategoryRelevance::Highest);
0150 
0151             // We got perfect match by one of the email addresses
0152         } else if (emails.contains(queryString)) {
0153             match.setCategoryRelevance(QueryMatch::CategoryRelevance::Highest);
0154             matchedEmail = queryString;
0155 
0156             // We got partial match either by name, or email
0157         } else {
0158             match.setCategoryRelevance(QueryMatch::CategoryRelevance::Low);
0159 
0160             // See if the match was by one of the email addresses
0161             for (const QString &email : emails) {
0162                 if (email.startsWith(queryString)) {
0163                     matchedEmail = email;
0164                     break;
0165                 }
0166             }
0167         }
0168         // If we had an email match, then use it, otherwise assume name-based
0169         // match and explode the contact to all available email addresses
0170         if (!matchedEmail.isEmpty()) {
0171             if (!mListEmails.contains(matchedEmail)) {
0172                 mListEmails.append(matchedEmail);
0173                 match.setText(i18nc("Name (email)", "%1 (%2)", name, matchedEmail));
0174                 match.setData(QStringLiteral("mailto:%1<%2>").arg(name, matchedEmail));
0175                 context.addMatch(match);
0176             }
0177         } else {
0178             for (const QString &email : emails) {
0179                 if (!mListEmails.contains(email)) {
0180                     mListEmails.append(email);
0181                     QueryMatch alternativeMatch = match;
0182                     alternativeMatch.setText(i18nc("Name (email)", "%1 (%2)", name, email));
0183                     alternativeMatch.setData(QStringLiteral("mailto:%1<%2>").arg(name, email));
0184                     context.addMatch(alternativeMatch);
0185                 }
0186             }
0187         }
0188     }
0189 }
0190 
0191 void PIMContactsRunner::queryAutocompleter(RunnerContext &context, const QString &queryString)
0192 {
0193     Akonadi::Search::PIM::ContactCompleter completer(queryString);
0194     const QStringList completerResults = completer.complete();
0195     qCDebug(AKONADI_KRUNNER_LOG) << "Autocompleter returned" << completerResults.count() << "results";
0196     for (const QString &result : completerResults) {
0197         // Filter out results where writing a mail wouldn't make sense,
0198         // e.g. anything with noreply or various automatic emails from git forges
0199         static const std::array filters = {
0200             QRegularExpression(QStringLiteral("no[-]?reply@")),
0201             QRegularExpression(QStringLiteral("incoming\\+.+@invent\\.kde\\.org")),
0202             QRegularExpression(QStringLiteral("reply\\+.+@reply\\.github\\.com")),
0203             QRegularExpression(QStringLiteral("@noreply\\.github\\.com")),
0204             QRegularExpression(QStringLiteral("notifications@github\\.com")),
0205             QRegularExpression(QStringLiteral("incoming\\+.+@gitlab\\.com")),
0206             QRegularExpression(QStringLiteral("gitlab@gitlab\\.freedesktop\\.org")),
0207         };
0208 
0209         const bool skip = std::any_of(filters.cbegin(), filters.cend(), [result](const QRegularExpression &filter) {
0210             return result.contains(filter);
0211         });
0212 
0213         if (skip) {
0214             continue;
0215         }
0216 
0217         QueryMatch match(this);
0218         match.setRelevance(0.7); // slightly lower relevance than real addressbook contacts
0219         match.setMatchCategory(i18n("Contacts"));
0220         match.setSubtext(i18n("Autocompleted from received and sent emails"));
0221         match.setIcon(QIcon::fromTheme(QStringLiteral("user-identity")));
0222         if (result == queryString) {
0223             match.setCategoryRelevance(QueryMatch::CategoryRelevance::Highest);
0224         } else {
0225             match.setCategoryRelevance(QueryMatch::CategoryRelevance::Low);
0226         }
0227 
0228         QString name;
0229         QString email;
0230         if (KEmailAddress::extractEmailAddressAndName(result, email, name)) {
0231             if (mListEmails.contains(email)) {
0232                 continue;
0233             }
0234             mListEmails.append(email);
0235             if (name.isEmpty()) {
0236                 match.setText(email);
0237                 match.setData(QStringLiteral("mailto:%1").arg(email));
0238             } else {
0239                 match.setText(i18nc("Name (email)", "%1 (%2)", name, email));
0240                 match.setData(QStringLiteral("mailto:%1<%2>").arg(name, email));
0241             }
0242         } else {
0243             if (mListEmails.contains(result)) {
0244                 continue;
0245             }
0246             mListEmails.append(result);
0247             match.setText(result);
0248             match.setData(QStringLiteral("mailto:%1").arg(result));
0249         }
0250         context.addMatch(match);
0251     }
0252 }
0253 
0254 void PIMContactsRunner::run(const RunnerContext &context, const QueryMatch &match)
0255 {
0256     Q_UNUSED(context)
0257 
0258     const QString mailto = match.data().toString();
0259     if (!mailto.isEmpty()) {
0260         QDesktopServices::openUrl(QUrl::fromUserInput(mailto));
0261     }
0262 }
0263 
0264 K_PLUGIN_CLASS_WITH_JSON(PIMContactsRunner, "plasma-krunner-pimcontacts.json")
0265 
0266 #include "pimcontactsrunner.moc"
0267 
0268 #include "moc_pimcontactsrunner.cpp"