diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index e6d22fa..ee5d2a0 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -1,283 +1,291 @@ # SPDX-FileCopyrightText: 2023 g10 code GmbH # SPDX-Contributor: Carl Schwan # SPDX-License-Identifier: BSD-2-Clause add_library(gpgol-server-static STATIC) target_sources(gpgol-server-static PRIVATE websocketclient.cpp websocketclient.h webserver.h webserver.cpp # Identity identity/addressvalidationjob.cpp identity/addressvalidationjob.h identity/identitymanager.cpp identity/identitymanager.h identity/identitydialog.cpp identity/identitydialog.h identity/identityaddvcarddialog.cpp identity/identityaddvcarddialog.h # HTTP Controller controllers/emailcontroller.cpp controllers/emailcontroller.h # Draft draft/draft.cpp draft/draft.h draft/draftmanager.cpp draft/draftmanager.h # EWS integration ews/ewsattachment.cpp ews/ewsattachment.h ews/ewsattendee.cpp ews/ewsattendee.h ews/ewsclient_debug.cpp ews/ewsclient_debug.h ews/ewsid.cpp ews/ewsid.h ews/ewsitem.cpp ews/ewsitem.h ews/ewsitembase.cpp ews/ewsitembase.h ews/ewsitembase_p.h ews/ewsmailbox.cpp ews/ewsmailbox.h ews/ewsmailfactory.cpp ews/ewsmailfactory.h ews/ewsoccurrence.cpp ews/ewsoccurrence.h ews/ewspropertyfield.cpp ews/ewspropertyfield.h ews/ewsrecurrence.cpp ews/ewsrecurrence.h ews/ewsserverversion.cpp ews/ewsserverversion.h ews/ewstypes.cpp ews/ewstypes.h ews/ewsxml.cpp ews/ewsxml.h # Editor + editor/addresseelineedit.cpp + editor/addresseelineedit.h + editor/addresseelineeditmanager.cpp + editor/addresseelineeditmanager.h + editor/certificatelineedit.cpp + editor/certificatelineedit.h editor/composer.cpp editor/composer.h editor/composerviewbase.cpp editor/composerviewbase.h editor/composerwindow.cpp editor/composerwindow.h editor/composerwindowfactory.cpp editor/composerwindowfactory.h editor/cryptostateindicatorwidget.cpp editor/cryptostateindicatorwidget.h editor/encryptionstate.cpp editor/encryptionstate.h editor/kmcomposerglobalaction.cpp editor/kmcomposerglobalaction.h editor/nearexpirywarning.cpp editor/nearexpirywarning.h editor/mailtemplates.cpp editor/mailtemplates.h editor/recipient.cpp editor/recipient.h editor/recipientline.cpp editor/recipientline.h editor/recipientseditor.cpp editor/recipientseditor.h editor/util.h editor/util.cpp + editor/kmailcompletion.cpp + editor/kmailcompletion.h editor/richtextcomposerng.cpp editor/richtextcomposerng.h editor/richtextcomposersignatures.cpp editor/richtextcomposersignatures.h editor/nodehelper.cpp editor/nodehelper.h editor/signaturecontroller.cpp editor/signaturecontroller.h editor/spellcheckerconfigdialog.cpp editor/spellcheckerconfigdialog.h # Editor job editor/job/abstractencryptjob.h editor/job/autocryptheadersjob.h editor/job/contentjobbase.h editor/job/contentjobbase_p.h editor/job/dndfromarkjob.cpp editor/job/dndfromarkjob.h editor/job/encryptjob.h editor/job/inserttextfilejob.h editor/job/itipjob.h editor/job/jobbase.h editor/job/jobbase_p.h editor/job/maintextjob.h editor/job/multipartjob.h editor/job/protectedheadersjob.h editor/job/signencryptjob.h editor/job/signjob.h editor/job/singlepartjob.h editor/job/skeletonmessagejob.h editor/job/transparentjob.h editor/job/autocryptheadersjob.cpp editor/job/contentjobbase.cpp editor/job/encryptjob.cpp editor/job/inserttextfilejob.cpp editor/job/itipjob.cpp editor/job/jobbase.cpp editor/job/maintextjob.cpp editor/job/multipartjob.cpp editor/job/protectedheadersjob.cpp editor/job/saveasfilejob.cpp editor/job/saveasfilejob.h editor/job/signencryptjob.cpp editor/job/signjob.cpp editor/job/singlepartjob.cpp editor/job/skeletonmessagejob.cpp editor/job/transparentjob.cpp ## Editor Part editor/part/globalpart.h editor/part/infopart.h editor/part/itippart.h editor/part/messagepart.h editor/part/textpart.h editor/part/globalpart.cpp editor/part/infopart.cpp editor/part/itippart.cpp editor/part/messagepart.cpp editor/part/textpart.cpp ## Attachment editor/attachment/attachmentjob.cpp editor/attachment/attachmentjob.h editor/attachment/attachmentclipboardjob.cpp editor/attachment/attachmentclipboardjob.h editor/attachment/attachmentcompressjob.cpp editor/attachment/attachmentcompressjob.h editor/attachment/attachmentcontroller.cpp editor/attachment/attachmentcontroller.h editor/attachment/attachmentcontrollerbase.cpp editor/attachment/attachmentcontrollerbase.h editor/attachment/attachmentfromfolderjob.cpp editor/attachment/attachmentfromfolderjob.h editor/attachment/attachmentfrommimecontentjob.cpp editor/attachment/attachmentfrommimecontentjob.h editor/attachment/attachmentfromurlbasejob.cpp editor/attachment/attachmentfromurlbasejob.h editor/attachment/attachmentfromurljob.cpp editor/attachment/attachmentfromurljob.h editor/attachment/attachmentfromurlutils.cpp editor/attachment/attachmentfromurlutils.h editor/attachment/attachmentfrompublickeyjob.cpp editor/attachment/attachmentfrompublickeyjob.h editor/attachment/attachmentloadjob.cpp editor/attachment/attachmentloadjob.h editor/attachment/attachmentmodel.cpp editor/attachment/attachmentmodel.h editor/attachment/attachmentpart.cpp editor/attachment/attachmentpart.h editor/attachment/attachmentpropertiesdialog.cpp editor/attachment/attachmentpropertiesdialog.h editor/attachment/attachmentupdatejob.cpp editor/attachment/attachmentupdatejob.h editor/attachment/attachmentview.cpp editor/attachment/attachmentview.h editor/attachment/editorwatcher.cpp editor/attachment/editorwatcher.h ) qt_add_resources(gpgol-server-static PREFIX "/" FILES assets/certificate.crt ) ki18n_wrap_ui(gpgol-server-static editor/attachment/ui/attachmentpropertiesdialog.ui editor/attachment/ui/attachmentpropertiesdialog_readonly.ui ) ecm_qt_declare_logging_category(gpgol-server-static_SRCS HEADER websocket_debug.h IDENTIFIER WEBSOCKET_LOG CATEGORY_NAME org.gpgol.server.websocket DESCRIPTION "Websocket connection in the server" EXPORT GPGOL ) ecm_qt_declare_logging_category(gpgol-server-static_SRCS HEADER ewsresource_debug.h IDENTIFIER EWSRES_LOG CATEGORY_NAME org.gpgol.ews DESCRIPTION "Ews mail client" EXPORT GPGOL ) ecm_qt_declare_logging_category(gpgol-server-static_SRCS HEADER ewscli_debug.h IDENTIFIER EWSCLI_LOG CATEGORY_NAME org.gpgol.ews.client DESCRIPTION "ews client (gpgol-server)" EXPORT GPGOL ) ecm_qt_declare_logging_category(gpgol-server-static_SRCS HEADER editor_debug.h IDENTIFIER EDITOR_LOG CATEGORY_NAME org.gpgol.editor DESCRIPTION "mail composer" EXPORT GPGOL ) set(WARN_TOOMANY_RECIPIENTS_DEFAULT true) set(ALLOW_SEMICOLON_AS_ADDRESS_SEPARATOR_DEFAULT true) configure_file(editor/settings/messagecomposer.kcfg.in ${CMAKE_CURRENT_BINARY_DIR}/messagecomposer.kcfg) kconfig_add_kcfg_files(gpgol-server-static editor/settings/messagecomposersettings.kcfgc) install(FILES composerui.rc DESTINATION ${KDE_INSTALL_KXMLGUIDIR}/gpgol-server) target_sources(gpgol-server-static PUBLIC ${gpgol-server-static_SRCS}) target_link_libraries(gpgol-server-static PUBLIC common Qt6::HttpServer Qt6::Widgets Qt6::PrintSupport KF6::CalendarCore KF6::ConfigCore KF6::ConfigGui KF6::Contacts KF6::Completion KF6::CoreAddons KF6::ColorScheme KF6::Codecs KF6::GuiAddons KF6::SonnetUi KF6::WidgetsAddons KF6::XmlGui KF6::KIOFileWidgets KF6::Archive KF6::TextAutoCorrectionCore KPim6::MimeTreeParserWidgets KPim6::Libkleo KPim6::Libkdepim KPim6::LdapWidgets KPim6::PimTextEdit KPim6::IdentityManagementCore KPim6::IdentityManagementWidgets ) add_executable(gpgol-server main.cpp) target_link_libraries(gpgol-server PRIVATE gpgol-server-static) if (BUILD_TESTING) add_subdirectory(autotests) endif() diff --git a/server/editor/addresseelineedit.cpp b/server/editor/addresseelineedit.cpp new file mode 100644 index 0000000..67333e3 --- /dev/null +++ b/server/editor/addresseelineedit.cpp @@ -0,0 +1,1163 @@ +/* + This file is part of libkdepim. + + SPDX-FileCopyrightText: 2002 Helge Deller + SPDX-FileCopyrightText: 2002 Lubos Lunak + SPDX-FileCopyrightText: 2001, 2003 Carsten Pfeiffer + SPDX-FileCopyrightText: 2001 Waldo Bastian + SPDX-FileCopyrightText: 2004 Daniel Molkentin + SPDX-FileCopyrightText: 2004 Karl-Heinz Zimmer + SPDX-FileCopyrightText: 2017-2024 Laurent Montel + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#include "addresseelineedit.h" +#include "addresseelineeditmanager.h" +#include "kmailcompletion.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using namespace Qt::Literals::StringLiterals; + +static const QString s_completionItemIndentString = QStringLiteral(" "); + +inline bool itemIsHeader(const QListWidgetItem *item) +{ + return item && !item->text().startsWith(QLatin1StringView(" ")); +} + +QString adaptPasteMails(const QString &str) +{ + QString newText = str; + // remove newlines in the to-be-pasted string + static QRegularExpression reg2(QStringLiteral("\r?\n")); + QStringList lines = newText.split(reg2, Qt::SkipEmptyParts); + QStringList::iterator end(lines.end()); + for (QStringList::iterator it = lines.begin(); it != end; ++it) { + // remove trailing commas and whitespace + static QRegularExpression reg1(QRegularExpression(QStringLiteral(",?\\s*$"))); + it->remove(reg1); + } + newText = lines.join(QLatin1StringView(", ")); + + if (newText.startsWith(QLatin1StringView("mailto:"))) { + const QUrl url(newText); + newText = url.path(); + } else if (newText.contains(QLatin1StringView(" at "))) { + // Anti-spam stuff + newText.replace(QStringLiteral(" at "), QStringLiteral("@")); + newText.replace(QStringLiteral(" dot "), QStringLiteral(".")); + } else if (newText.contains(QLatin1StringView("(at)"))) { + static QRegularExpression reg((QStringLiteral("\\s*\\(at\\)\\s*"))); + newText.replace(reg, QStringLiteral("@")); + } + return newText; +} + +class SourceWithWeight +{ +public: + int weight; // the weight of the source + int index; // index into AddresseeLineEditStatic::self()->completionSources + QString sourceName; // the name of the source, e.g. "LDAP Server" + + bool operator<(const SourceWithWeight &other) const + { + if (weight > other.weight) { + return true; + } + + if (weight < other.weight) { + return false; + } + + return sourceName < other.sourceName; + } +}; + +class AddresseeLineEditPrivate : public QObject +{ + Q_OBJECT + +public: + AddresseeLineEditPrivate(AddresseeLineEdit *qq); + + void setCompletedItems(const QStringList &items, bool autoSuggest); + void addCompletionItem(const QString &string, int weight, int completionItemSource, const QStringList *keyWords = nullptr); + bool smartPaste() const; + void setSmartPaste(bool smartPaste); + bool completionInitialized() const; + bool useCompletion() const; + void setUseCompletion(bool useCompletion); + + const QStringList adjustedCompletionItems(bool fullSearch); + void updateSearchString(); + void startSearches(); + void slotCompletion(); + void doCompletion(bool ctrlT); + + void searchInGnupg(); + + void slotTriggerDelayedQueries(); + + [[nodiscard]] QString searchString() const; + void setSearchString(const QString &searchString); + + [[nodiscard]] bool searchExtended() const; + void setSearchExtended(bool searchExtended); + + [[nodiscard]] bool useSemicolonAsSeparator() const; + void setUseSemicolonAsSeparator(bool useSemicolonAsSeparator); + + void slotReturnPressed(const QString &); + void slotPopupCompletion(const QString &completion); + void slotUserCancelled(const QString &cancelText); + +private: + AddresseeLineEdit * const q; + QString mSearchString; + QString mPreviousAddresses; + bool mSmartPaste; + bool mUseCompletion = true; + bool mCompletionInitialized = false; + bool mLastSearchMode; + bool mSearchExtended = false; + bool mUseSemicolonAsSeparator = false; + + QTimer *mDelayedQueryTimer; +}; + +AddresseeLineEditPrivate::AddresseeLineEditPrivate(AddresseeLineEdit *qq) + : QObject(qq) + , q(qq) + , mDelayedQueryTimer(new QTimer(this)) +{ + mDelayedQueryTimer->setSingleShot(true); + connect(mDelayedQueryTimer, &QTimer::timeout, this, &AddresseeLineEditPrivate::slotTriggerDelayedQueries); + + if (!mCompletionInitialized) { + q->setCompletionObject(AddresseeLineEditManager::self()->completion(), false); + connect(q, &KLineEdit::completion, this, &AddresseeLineEditPrivate::slotCompletion); + connect(q, &AddresseeLineEdit::returnKeyPressed, this, &AddresseeLineEditPrivate::slotReturnPressed); + + KCompletionBox *box = q->completionBox(); + connect(box, &KCompletionBox::textActivated, this, &AddresseeLineEditPrivate::slotPopupCompletion); + connect(box, &KCompletionBox::userCancelled, this, &AddresseeLineEditPrivate::slotUserCancelled); + + mCompletionInitialized = true; + } + + connect(q, &AddresseeLineEdit::textCompleted, q, &AddresseeLineEdit::slotEditingFinished); + connect(q, &AddresseeLineEdit::editingFinished, q, &AddresseeLineEdit::slotEditingFinished); + + AddresseeLineEditManager::self()->addCompletionSource(i18n("GPG keychain"), 100); +} + +void AddresseeLineEditPrivate::slotUserCancelled(const QString &cancelText) +{ + q->callUserCancelled(mPreviousAddresses + cancelText); // in KLineEdit +} + +void AddresseeLineEditPrivate::slotPopupCompletion(const QString &completion) +{ + QString c = completion.trimmed(); + if (c.endsWith(QLatin1Char(')'))) { + c = completion.mid(0, completion.lastIndexOf(QLatin1StringView(" ("))).trimmed(); + } + q->setText(mPreviousAddresses + c); + q->cursorAtEnd(); + updateSearchString(); + q->emitTextCompleted(); +} + + +void AddresseeLineEditPrivate::slotReturnPressed(const QString &) +{ + if (!q->completionBox()->selectedItems().isEmpty()) { + slotPopupCompletion(q->completionBox()->selectedItems().constFirst()->text()); + } +} + +bool AddresseeLineEditPrivate::useSemicolonAsSeparator() const +{ + return mUseSemicolonAsSeparator; +} + +void AddresseeLineEditPrivate::setUseSemicolonAsSeparator(bool useSemicolonAsSeparator) +{ + mUseSemicolonAsSeparator = useSemicolonAsSeparator; +} + +const QStringList AddresseeLineEditPrivate::adjustedCompletionItems(bool fullSearch) +{ + QStringList items = fullSearch ? AddresseeLineEditManager::self()->completion()->allMatches(mSearchString) + : AddresseeLineEditManager::self()->completion()->substringCompletion(mSearchString); + + // force items to be sorted by email + items.sort(); + + // For weighted mode, the algorithm is the following: + // In the first loop, we add each item to its section (there is one section per completion source) + // We also add spaces in front of the items. + // The sections are appended to the items list. + // In the second loop, we then walk through the sections and add all the items in there to the + // sorted item list, which is the final result. + // + // The algo for non-weighted mode is different. + + int lastSourceIndex = -1; + + // Maps indices of the items list, which are section headers/source items, + // to a QStringList which are the items of that section/source. + QMap sections; + QStringList sortedItems; + for (QStringList::Iterator it = items.begin(); it != items.end(); ++it) { + auto cit = AddresseeLineEditManager::self()->completionItemsMap.constFind(*it); + if (cit == AddresseeLineEditManager::self()->completionItemsMap.constEnd()) { + continue; + } + + const int index = (*cit).second; + + if (AddresseeLineEditManager::self()->completion()->order() == KCompletion::Weighted) { + if (lastSourceIndex == -1 || lastSourceIndex != index) { + const QString sourceLabel(AddresseeLineEditManager::self()->completionSources.at(index)); + if (sections.find(index) == sections.end()) { + it = items.insert(it, sourceLabel); + ++it; // skip new item + } + lastSourceIndex = index; + } + + it->prepend(s_completionItemIndentString); + // remove preferred email sort added in addContact() + it->replace(QLatin1StringView(" <"), QStringLiteral(" <")); + } + sections[index].append(*it); + + if (AddresseeLineEditManager::self()->completion()->order() == KCompletion::Sorted) { + sortedItems.append(*it); + } + } + + if (AddresseeLineEditManager::self()->completion()->order() == KCompletion::Weighted) { + // Sort the sections + QList sourcesAndWeights; + const int numberOfCompletionSources(AddresseeLineEditManager::self()->completionSources.count()); + sourcesAndWeights.reserve(numberOfCompletionSources); + for (int i = 0; i < numberOfCompletionSources; ++i) { + SourceWithWeight sww; + sww.sourceName = AddresseeLineEditManager::self()->completionSources.at(i); + sww.weight = AddresseeLineEditManager::self()->completionSourceWeights[sww.sourceName]; + sww.index = i; + sourcesAndWeights.append(sww); + } + + std::sort(sourcesAndWeights.begin(), sourcesAndWeights.end()); + // Add the sections and their items to the final sortedItems result list + const int numberOfSources(sourcesAndWeights.size()); + for (int i = 0; i < numberOfSources; ++i) { + const SourceWithWeight source = sourcesAndWeights.at(i); + const QStringList sectionItems = sections[source.index]; + if (!sectionItems.isEmpty()) { + sortedItems.append(source.sourceName); + for (const QString &itemInSection : sectionItems) { + sortedItems.append(itemInSection); + } + } + } + } else { + sortedItems.sort(); + } + + return sortedItems; +} + +void AddresseeLineEditPrivate::doCompletion(bool ctrlT) +{ + mLastSearchMode = ctrlT; + + const KCompletion::CompletionMode mode = q->completionMode(); + + if (mode == KCompletion::CompletionNone) { + return; + } + + AddresseeLineEditManager::self()->completion()->setOrder(KCompletion::Weighted); + + // cursor at end of string - or Ctrl+T pressed for substring completion? + if (ctrlT) { + const QStringList completions = adjustedCompletionItems(false); + + if (completions.count() == 1) { + q->setText(mPreviousAddresses + completions.first().trimmed()); + } + + // Make sure the completion popup is closed if no matching items were found + setCompletedItems(completions, true); + + q->cursorAtEnd(); + q->setCompletionMode(mode); // set back to previous mode + return; + } + + switch (mode) { + case KCompletion::CompletionPopupAuto: + if (mSearchString.isEmpty()) { + break; + } + // else: fall-through to the CompletionPopup case + [[fallthrough]]; + + case KCompletion::CompletionPopup: { + const QStringList items = adjustedCompletionItems(false); + setCompletedItems(items, false); + break; + } + + case KCompletion::CompletionShell: { + const QString match = AddresseeLineEditManager::self()->completion()->makeCompletion(mSearchString); + if (!match.isNull() && match != mSearchString) { + q->setText(mPreviousAddresses + match); + q->setModified(true); + q->cursorAtEnd(); + } + break; + } + + case KCompletion::CompletionMan: // Short-Auto in fact + case KCompletion::CompletionAuto: + // force autoSuggest in KLineEdit::keyPressed or setCompletedText will have no effect + q->setCompletionMode(q->completionMode()); + + if (!mSearchString.isEmpty()) { + // if only our \" is left, remove it since user has not typed it either + if (mSearchExtended && mSearchString == QLatin1StringView("\"")) { + mSearchExtended = false; + mSearchString.clear(); + q->setText(mPreviousAddresses); + break; + } + + QString match = AddresseeLineEditManager::self()->completion()->makeCompletion(mSearchString); + + if (!match.isEmpty()) { + if (match != mSearchString) { + const QString adds = mPreviousAddresses + match; + q->callSetCompletedText(adds); + } + } else { + if (!mSearchString.startsWith(QLatin1Char('\"'))) { + // try with quoted text, if user has not type one already + match = AddresseeLineEditManager::self()->completion()->makeCompletion(QLatin1StringView("\"") + mSearchString); + if (!match.isEmpty() && match != mSearchString) { + mSearchString = QLatin1StringView("\"") + mSearchString; + mSearchExtended = true; + q->setText(mPreviousAddresses + mSearchString); + q->callSetCompletedText(mPreviousAddresses + match); + } + } else if (mSearchExtended) { + // our added \" does not work anymore, remove it + mSearchString.remove(0, 1); + mSearchExtended = false; + q->setText(mPreviousAddresses + mSearchString); + // now try again + match = AddresseeLineEditManager::self()->completion()->makeCompletion(mSearchString); + if (!match.isEmpty() && match != mSearchString) { + const QString adds = mPreviousAddresses + match; + q->setCompletedText(adds); + } + } + } + } + break; + + case KCompletion::CompletionNone: + break; + } +} + +void AddresseeLineEditPrivate::setCompletedItems(const QStringList &items, bool autoSuggest) +{ + KCompletionBox *completionBox = q->completionBox(); + + if (!items.isEmpty() && !(items.count() == 1 && mSearchString == items.first())) { + completionBox->clear(); + const int numberOfItems(items.count()); + for (int i = 0; i < numberOfItems; ++i) { + auto item = new QListWidgetItem(items.at(i), completionBox); + if (!items.at(i).startsWith(s_completionItemIndentString)) { + item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + item->setBackground(AddresseeLineEditManager::self()->alternateColor()); + } + completionBox->addItem(item); + } + if (!completionBox->isVisible()) { + if (!mSearchString.isEmpty()) { + completionBox->setCancelledText(mSearchString); + } + completionBox->popup(); + // we have to install the event filter after popup(), since that + // calls show(), and that's where KCompletionBox installs its filter. + // We want to be first, though, so do it now. + if (AddresseeLineEditManager::self()->completion()->order() == KCompletion::Weighted) { + qApp->installEventFilter(q); + } + } + + QListWidgetItem *item = completionBox->item(1); + if (item) { + completionBox->blockSignals(true); + completionBox->setCurrentItem(item); + item->setSelected(true); + completionBox->blockSignals(false); + } + + if (autoSuggest) { + const int index = items.first().indexOf(mSearchString); + const QString newText = items.first().mid(index); + q->callSetUserSelection(false); + q->callSetCompletedText(newText, true); + } + } else { + if (completionBox && completionBox->isVisible()) { + completionBox->hide(); + completionBox->setItems(QStringList()); + } + } +} + +void AddresseeLineEditPrivate::addCompletionItem(const QString &string, int weight, int completionItemSource, const QStringList *keyWords) +{ + // Check if there is an exact match for item already, and use the + // maximum weight if so. Since there's no way to get the information + // from KCompletion, we have to keep our own QMap. + // We also update the source since the item should always be shown from the source with the highest weight + + auto manager = AddresseeLineEditManager::self(); + auto it = manager->completionItemsMap.find(string); + if (it != manager->completionItemsMap.end()) { + weight = qMax((*it).first, weight); + (*it).first = weight; + (*it).second = completionItemSource; + } else { + manager->completionItemsMap.insert(string, qMakePair(weight, completionItemSource)); + } + + manager->completion()->addItem(string, weight); + if (keyWords && !keyWords->isEmpty()) { + manager->completion()->addItemWithKeys(string, weight, keyWords); // see kmailcompletion.cpp + } +} + +bool AddresseeLineEditPrivate::smartPaste() const +{ + return mSmartPaste; +} + +void AddresseeLineEditPrivate::setSmartPaste(bool smartPaste) +{ + mSmartPaste = smartPaste; +} + +bool AddresseeLineEditPrivate::completionInitialized() const +{ + return mCompletionInitialized; +} + +bool AddresseeLineEditPrivate::useCompletion() const +{ + return mUseCompletion; +} + +void AddresseeLineEditPrivate::setUseCompletion(bool useCompletion) +{ + mUseCompletion = useCompletion; +} + +AddresseeLineEdit::AddresseeLineEdit(QWidget *parent) + : KLineEdit(parent) + , d(std::make_unique(this)) +{} + +AddresseeLineEdit::~AddresseeLineEdit() = default; + +void AddresseeLineEdit::addContactGroup(const KContacts::ContactGroup &group, int weight, int source) +{ + d->addCompletionItem(group.name(), weight, source); +} + +void AddresseeLineEdit::addContact(const QStringList &emails, const KContacts::Addressee &addr, int weight, int source, QString append) +{ + int isPrefEmail = 1; // first in list is preferredEmail + for (const QString &email : emails) { + // TODO: highlight preferredEmail + const QString givenName = addr.givenName(); + const QString familyName = addr.familyName(); + const QString nickName = addr.nickName(); + const QString fullEmail = addr.fullEmail(email); + + QString appendix; + + if (!append.isEmpty()) { + appendix = QStringLiteral(" (%1)"); + append.replace(QLatin1Char('('), QStringLiteral("[")); + append.replace(QLatin1Char(')'), QStringLiteral("]")); + appendix = appendix.arg(append); + } + + // Prepare "givenName" + ' ' + "familyName" + QString fullName = givenName; + if (!familyName.isEmpty()) { + if (!fullName.isEmpty()) { + fullName += QLatin1Char(' '); + } + fullName += familyName; + } + + // Finally, we can add the completion items + if (!fullName.isEmpty()) { + const QString address = KEmailAddress::normalizedAddress(fullName, email, QString()); + if (fullEmail != address) { + // This happens when fullEmail contains a middle name, while our own fullName+email only has "first last". + // Let's offer both, the fullEmail with 3 parts, looks a tad formal. + d->addCompletionItem(address + appendix, weight + isPrefEmail, source); + } + } + + QStringList keyWords; + if (!nickName.isEmpty()) { + keyWords.append(nickName); + } + + d->addCompletionItem(fullEmail + appendix, weight + isPrefEmail, source, &keyWords); + + isPrefEmail = 0; + } +} + +void AddresseeLineEdit::addContact(const KContacts::Addressee &addr, int weight, int source, const QString &append) +{ + const QStringList emails = addr.emails(); + if (emails.isEmpty()) { + return; + } + addContact(emails, addr, weight, source, append); +} + +void AddresseeLineEdit::dropEvent(QDropEvent *event) +{ + const QMimeData *md = event->mimeData(); + // Case one: The user dropped a text/directory (i.e. vcard), so decode its + // contents + if (KContacts::VCardDrag::canDecode(md)) { + KContacts::Addressee::List list; + KContacts::VCardDrag::fromMimeData(md, list); + + for (const KContacts::Addressee &addr : std::as_const(list)) { + insertEmails(addr.emails()); + } + } + // Case two: The user dropped a list or Urls. + // Iterate over that list. For mailto: Urls, just add the addressee to the list, + // and for other Urls, download the Url and assume it points to a vCard + else if (md->hasUrls()) { + const QList urls = md->urls(); + KContacts::Addressee::List list; + + for (const QUrl &url : urls) { + // First, let's deal with mailto Urls. The path() part contains the + // email-address. + if (url.scheme() == QLatin1StringView("mailto")) { + KContacts::Addressee addressee; + KContacts::Email email(KEmailAddress::decodeMailtoUrl(url)); + email.setPreferred(true); + addressee.addEmail(email); + list += addressee; + } + } + + // Now, let the user choose which addressee to add. + for (const KContacts::Addressee &addressee : std::as_const(list)) { + insertEmails(addressee.emails()); + } + } + // Case three: Let AddresseeLineEdit deal with the rest + else { + if (!isReadOnly()) { + const QList uriList = event->mimeData()->urls(); + if (!uriList.isEmpty()) { + QString contents = text(); + // remove trailing white space and comma + int eot = contents.length(); + while ((eot > 0) && contents.at(eot - 1).isSpace()) { + --eot; + } + if (eot == 0) { + contents.clear(); + } else if (contents.at(eot - 1) == QLatin1Char(',')) { + --eot; + contents.truncate(eot); + } + bool mailtoURL = false; + // append the mailto URLs + for (const QUrl &url : uriList) { + if (url.scheme() == QLatin1StringView("mailto")) { + mailtoURL = true; + QString address; + address = QUrl::fromPercentEncoding(url.path().toLatin1()); + address = KCodecs::decodeRFC2047String(address); + if (!contents.isEmpty()) { + contents.append(QLatin1StringView(", ")); + } + contents.append(address); + } + } + if (mailtoURL) { + setText(contents); + setModified(true); + return; + } + } else { + // Let's see if this drop contains a comma separated list of emails + if (md->hasText()) { + const QString dropData = md->text(); + const QStringList addrs = KEmailAddress::splitAddressList(dropData); + if (!addrs.isEmpty()) { + if (addrs.count() == 1) { + QUrl url(dropData); + if (url.scheme() == QLatin1StringView("mailto")) { + KContacts::Addressee addressee; + KContacts::Email email(KEmailAddress::decodeMailtoUrl(url)); + email.setPreferred(true); + addressee.addEmail(email); + insertEmails(addressee.emails()); + } else { + setText(KEmailAddress::normalizeAddressesAndDecodeIdn(dropData)); + } + } else { + setText(KEmailAddress::normalizeAddressesAndDecodeIdn(dropData)); + } + setModified(true); + return; + } + } + } + } + + if (d->useCompletion()) { + d->setSmartPaste(true); + } + + QLineEdit::dropEvent(event); + d->setSmartPaste(false); + } +} + +void AddresseeLineEdit::insertEmails(const QStringList &emails) +{ + if (emails.empty()) { + return; + } + + QString contents = text(); + if (!contents.isEmpty()) { + contents += QLatin1Char(','); + } + // only one address, don't need kpopup to choose + if (emails.size() == 1) { + setText(contents + emails.front()); + return; + } + // multiple emails, let the user choose one + QMenu menu(this); + menu.setTitle(i18n("Select email from contact")); + menu.setObjectName(QLatin1StringView("Addresschooser")); + for (const QString &email : emails) { + menu.addAction(email); + } + const QAction *result = menu.exec(QCursor::pos()); + if (!result) { + return; + } + setText(contents + KLocalizedString::removeAcceleratorMarker(result->text())); +} + +void AddresseeLineEdit::cursorAtEnd() +{ + setCursorPosition(text().length()); +} + +void AddresseeLineEdit::enableCompletion(bool enable) +{ + d->setUseCompletion(enable); +} + +bool AddresseeLineEdit::isCompletionEnabled() const +{ + return d->useCompletion(); +} + +bool AddresseeLineEdit::eventFilter(QObject *object, QEvent *event) +{ + if (d->completionInitialized() && (object == completionBox() || completionBox()->findChild(object->objectName()) == object)) { + if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseMove || event->type() == QEvent::MouseButtonRelease + || event->type() == QEvent::MouseButtonDblClick) { + const QMouseEvent *mouseEvent = static_cast(event); + // find list box item at the event position + QListWidgetItem *item = completionBox()->itemAt(mouseEvent->pos()); + if (!item) { + // In the case of a mouse move outside of the box we don't want + // the parent to fuzzy select a header by mistake. + const bool eat = event->type() == QEvent::MouseMove; + return eat; + } + // avoid selection of headers on button press, or move or release while + // a button is pressed + const Qt::MouseButtons buttons = mouseEvent->buttons(); + if (event->type() == QEvent::MouseButtonPress || event->type() == QEvent::MouseButtonDblClick || buttons & Qt::LeftButton + || buttons & Qt::MiddleButton || buttons & Qt::RightButton) { + if (itemIsHeader(item)) { + return true; // eat the event, we don't want anything to happen + } else { + // if we are not on one of the group heading, make sure the item + // below or above is selected, not the heading, inadvertedly, due + // to fuzzy auto-selection from QListBox + completionBox()->setCurrentItem(item); + item->setSelected(true); + if (event->type() == QEvent::MouseMove) { + return true; // avoid fuzzy selection behavior + } + } + } + } + } + + if ((object == this) && (event->type() == QEvent::ShortcutOverride)) { + auto keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Up || keyEvent->key() == Qt::Key_Down || keyEvent->key() == Qt::Key_Tab) { + keyEvent->accept(); + return true; + } + } + + if ((object == this) && (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) && completionBox()->isVisible()) { + const QKeyEvent *keyEvent = static_cast(event); + int currentIndex = completionBox()->currentRow(); + if (currentIndex < 0) { + return true; + } + if (keyEvent->key() == Qt::Key_Up) { + // qCDebug(PIMCOMMONAKONADI_LOG) <<"EVENTFILTER: Qt::Key_Up currentIndex=" << currentIndex; + // figure out if the item we would be moving to is one we want + // to ignore. If so, go one further + const QListWidgetItem *itemAbove = completionBox()->item(currentIndex); + if (itemAbove && itemIsHeader(itemAbove)) { + // there is a header above is, check if there is even further up + // and if so go one up, so it'll be selected + if (currentIndex > 0 && completionBox()->item(currentIndex - 1)) { + // qCDebug(PIMCOMMONAKONADI_LOG) <<"EVENTFILTER: Qt::Key_Up -> skipping" << currentIndex - 1; + completionBox()->setCurrentRow(currentIndex - 1); + completionBox()->item(currentIndex - 1)->setSelected(true); + } else if (currentIndex == 0) { + // nothing to skip to, let's stay where we are, but make sure the + // first header becomes visible, if we are the first real entry + completionBox()->scrollToItem(completionBox()->item(0)); + QListWidgetItem *item = completionBox()->item(currentIndex); + if (item) { + if (itemIsHeader(item)) { + currentIndex++; + item = completionBox()->item(currentIndex); + } + completionBox()->setCurrentItem(item); + item->setSelected(true); + } + } + + return true; + } + } else if (keyEvent->key() == Qt::Key_Down) { + // same strategy for downwards + // qCDebug(PIMCOMMONAKONADI_LOG) <<"EVENTFILTER: Qt::Key_Down. currentIndex=" << currentIndex; + const QListWidgetItem *itemBelow = completionBox()->item(currentIndex); + if (itemBelow && itemIsHeader(itemBelow)) { + if (completionBox()->item(currentIndex + 1)) { + // qCDebug(PIMCOMMONAKONADI_LOG) <<"EVENTFILTER: Qt::Key_Down -> skipping" << currentIndex+1; + completionBox()->setCurrentRow(currentIndex + 1); + completionBox()->item(currentIndex + 1)->setSelected(true); + } else { + // nothing to skip to, let's stay where we are + QListWidgetItem *item = completionBox()->item(currentIndex); + if (item) { + completionBox()->setCurrentItem(item); + item->setSelected(true); + } + } + + return true; + } + // special case of the initial selection, which is unfortunately a header. + // Setting it to selected tricks KCompletionBox into not treating is special + // and selecting making it current, instead of the one below. + QListWidgetItem *item = completionBox()->item(currentIndex); + if (item && itemIsHeader(item)) { + completionBox()->setCurrentItem(item); + item->setSelected(true); + } + } else if (event->type() == QEvent::KeyRelease && (keyEvent->key() == Qt::Key_Tab || keyEvent->key() == Qt::Key_Backtab)) { + /// first, find the header of the current section + QListWidgetItem *myHeader = nullptr; + int myHeaderIndex = -1; + const int iterationStep = keyEvent->key() == Qt::Key_Tab ? 1 : -1; + int index = qMin(qMax(currentIndex - iterationStep, 0), completionBox()->count() - 1); + while (index >= 0) { + if (itemIsHeader(completionBox()->item(index))) { + myHeader = completionBox()->item(index); + myHeaderIndex = index; + break; + } + + index--; + } + Q_ASSERT(myHeader); // we should always be able to find a header + + // find the next header (searching backwards, for Qt::Key_Backtab) + QListWidgetItem *nextHeader = nullptr; + + // when iterating forward, start at the currentindex, when backwards, + // one up from our header, or at the end + int j; + if (keyEvent->key() == Qt::Key_Tab) { + j = currentIndex; + } else { + index = myHeaderIndex; + if (index == 0) { + j = completionBox()->count() - 1; + } else { + j = (index - 1) % completionBox()->count(); + } + } + while ((nextHeader = completionBox()->item(j)) && nextHeader != myHeader) { + if (itemIsHeader(nextHeader)) { + break; + } + j = (j + iterationStep) % completionBox()->count(); + } + + if (nextHeader && nextHeader != myHeader) { + QListWidgetItem *item = completionBox()->item(j + 1); + if (item && !itemIsHeader(item)) { + completionBox()->setCurrentItem(item); + item->setSelected(true); + } + } + + return true; + } + } + + return KLineEdit::eventFilter(object, event); +} + +void AddresseeLineEdit::setText(const QString &text) +{ + const int cursorPos = cursorPosition(); + KLineEdit::setText(text.trimmed()); + setCursorPosition(cursorPos); +} + +void AddresseeLineEdit::insert(const QString &t) +{ + if (!d->smartPaste()) { + KLineEdit::insert(t); + return; + } + + QString newText = t.trimmed(); + if (newText.isEmpty()) { + return; + } + + newText = adaptPasteMails(newText); + + QString contents = text(); + int pos = cursorPosition(); + + if (hasSelectedText()) { + // Cut away the selection. + int start_sel = selectionStart(); + pos = start_sel; + contents = contents.left(start_sel) + contents.mid(start_sel + selectedText().length()); + } + + int eot = contents.length(); + while ((eot > 0) && contents.at(eot - 1).isSpace()) { + --eot; + } + if (eot == 0) { + contents.clear(); + } else if (pos >= eot) { + if (contents.at(eot - 1) == QLatin1Char(',')) { + --eot; + } + contents.truncate(eot); + contents += QStringLiteral(", "); + pos = eot + 2; + } + + contents = contents.left(pos) + newText + contents.mid(pos); + setText(contents); + setModified(true); + setCursorPosition(pos + newText.length()); +} + +void AddresseeLineEdit::paste() +{ + if (d->useCompletion()) { + d->setSmartPaste(true); + } + + KLineEdit::paste(); + d->setSmartPaste(false); +} + +void AddresseeLineEdit::keyPressEvent(QKeyEvent *event) +{ + bool accept = false; + + const int key = event->key() | event->modifiers(); + + if (KStandardShortcut::shortcut(KStandardShortcut::SubstringCompletion).contains(key)) { + // TODO: add LDAP substring lookup, when it becomes available in KPIM::LDAPSearch + d->updateSearchString(); + d->startSearches(); + d->doCompletion(true); + accept = true; + } else if (KStandardShortcut::shortcut(KStandardShortcut::TextCompletion).contains(key)) { + const int len = text().length(); + + if (len == cursorPosition()) { // at End? + d->updateSearchString(); + d->startSearches(); + d->doCompletion(true); + accept = true; + } + } + + const QString oldContent = text(); + if (!accept) { + KLineEdit::keyPressEvent(event); + } + + // if the text didn't change (eg. because a cursor navigation key was pressed) + // we don't need to trigger a new search + if (oldContent == text()) { + return; + } + + if (event->isAccepted()) { + d->updateSearchString(); + + QString searchString(d->searchString()); + // LDAP does not know about our string manipulation, remove it + if (d->searchExtended()) { + searchString = d->searchString().mid(1); + } + } +} + +QString AddresseeLineEditPrivate::searchString() const +{ + return mSearchString; +} + +void AddresseeLineEditPrivate::setSearchString(const QString &searchString) +{ + mSearchString = searchString; +} + +bool AddresseeLineEditPrivate::searchExtended() const +{ + return mSearchExtended; +} + +void AddresseeLineEditPrivate::setSearchExtended(bool searchExtended) +{ + mSearchExtended = searchExtended; +} + +void AddresseeLineEdit::callUserCancelled(const QString &str) +{ + userCancelled(str); +} + +void AddresseeLineEdit::callSetCompletedText(const QString &text, bool marked) +{ + setCompletedText(text, marked); +} + +void AddresseeLineEdit::callSetCompletedText(const QString &text) +{ + setCompletedText(text); +} + +void AddresseeLineEdit::callSetUserSelection(bool b) +{ + setUserSelection(b); +} + +void AddresseeLineEdit::mouseReleaseEvent(QMouseEvent *event) +{ + // reimplemented from QLineEdit::mouseReleaseEvent() + if (d->useCompletion() && QApplication::clipboard()->supportsSelection() && !isReadOnly() && event->button() == Qt::MiddleButton) { + d->setSmartPaste(true); + } + + KLineEdit::mouseReleaseEvent(event); + d->setSmartPaste(false); +} + +void AddresseeLineEditPrivate::updateSearchString() +{ + mSearchString = q->text(); + + int n = -1; + bool inQuote = false; + const int searchStringLength = mSearchString.length(); + for (int i = 0; i < searchStringLength; ++i) { + const QChar searchChar = mSearchString.at(i); + if (searchChar == QLatin1Char('"')) { + inQuote = !inQuote; + } + + if (searchChar == QLatin1Char('\\') && (i + 1) < searchStringLength && mSearchString.at(i + 1) == QLatin1Char('"')) { + ++i; + } + + if (inQuote) { + continue; + } + + if (i < searchStringLength && (searchChar == QLatin1Char(',') || (mUseSemicolonAsSeparator && searchChar == QLatin1Char(';')))) { + n = i; + } + } + + if (n >= 0) { + ++n; // Go past the "," + + const int len = mSearchString.length(); + + // Increment past any whitespace... + while (n < len && mSearchString.at(n).isSpace()) { + ++n; + } + + mPreviousAddresses = mSearchString.left(n); + mSearchString = mSearchString.mid(n).trimmed(); + } else { + mPreviousAddresses.clear(); + } +} + +void AddresseeLineEditPrivate::startSearches() +{ + if (!mDelayedQueryTimer->isActive()) { + mDelayedQueryTimer->start(50ms); + } +} + +void AddresseeLineEditPrivate::slotCompletion() +{ + // Called by KLineEdit's keyPressEvent for CompletionModes + // Auto,Popup -> new text, update search string. + // not called for CompletionShell, this is been taken care of + // in AddresseeLineEdit::keyPressEvent + + updateSearchString(); + if (q->completionBox()) { + q->completionBox()->setCancelledText(mSearchString); + } + + startSearches(); + doCompletion(false); +} + +void AddresseeLineEditPrivate::slotTriggerDelayedQueries() +{ + const QString strSearch = mSearchString.trimmed(); + if (strSearch.size() <= 2) { + return; + } + + searchInGnupg(); +} + +void AddresseeLineEditPrivate::searchInGnupg() +{ + const auto keys = Kleo::KeyCache::instance()->keys(); + const QString trimmedString = mSearchString.trimmed(); + + for (const auto &key : keys) { + for (int i = 0, count = key.numUserIDs(); i < count; i++) { + const auto email = QLatin1String(key.userID(i).email()); + const auto name = QLatin1String(key.userID(i).name()); + if (email.contains(trimmedString) || name.contains(trimmedString)) { + if (name.isEmpty()) { + addCompletionItem(email, 1, 0); + } else { + addCompletionItem(name + u" <"_s + email + u'>', 1, 0); + } + } + } + } +} + +void AddresseeLineEdit::slotEditingFinished() +{ + //const QList listJob = d->mightBeGroupJobs(); + //for (KJob *job : listJob) { + // disconnect(job); + // job->deleteLater(); + //} + + //d->mightBeGroupJobsClear(); + //d->groupsClear(); + + //if (!text().trimmed().isEmpty() && enableAkonadiSearch()) { + // const QStringList addresses = KEmailAddress::splitAddressList(text()); + // for (const QString &address : addresses) { + // auto job = new Akonadi::ContactGroupSearchJob(); + // connect(job, &Akonadi::ContactGroupSearchJob::result, this, &AddresseeLineEdit::slotGroupSearchResult); + // d->mightBeGroupJobsAdd(job); + // job->setQuery(Akonadi::ContactGroupSearchJob::Name, address); + // } + //} +} + +void AddresseeLineEdit::emitTextCompleted() +{ + Q_EMIT textCompleted(); +} + +#include "addresseelineedit.moc" diff --git a/server/editor/addresseelineedit.h b/server/editor/addresseelineedit.h new file mode 100644 index 0000000..86ca600 --- /dev/null +++ b/server/editor/addresseelineedit.h @@ -0,0 +1,114 @@ +/* + This file is part of libkdepim. + + SPDX-FileCopyrightText: 2002 Helge Deller + SPDX-FileCopyrightText: 2002 Lubos Lunak + SPDX-FileCopyrightText: 2001, 2003 Carsten Pfeiffer + SPDX-FileCopyrightText: 2001 Waldo Bastian + SPDX-FileCopyrightText: 2004 Daniel Molkentin + SPDX-FileCopyrightText: 2004 Karl-Heinz Zimmer + SPDX-FileCopyrightText: 2017-2024 Laurent Montel + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +#pragma once + +#include + +namespace KContacts +{ +class Addressee; +class ContactGroup; +} + +class AddresseeLineEditPrivate; + +class AddresseeLineEdit : public KLineEdit +{ + Q_OBJECT + +public: + explicit AddresseeLineEdit(QWidget *parent = nullptr); + ~AddresseeLineEdit(); + + /** + * Adds a new @p contact to the completion with a given + * @p weight + * @p source index + * @p append is added to completion string, but removed, when mail is selected. + */ + void addContact(const KContacts::Addressee &contact, int weight, int source = -1, const QString &append = QString()); + + /** + * Same as the above, but this time with contact groups. + */ + void addContactGroup(const KContacts::ContactGroup &group, int weight, int source = -1); + + [[nodiscard]] bool isCompletionEnabled() const; + + void callUserCancelled(const QString &str); + void callSetCompletedText(const QString & /*text*/, bool /*marked*/); + void callSetCompletedText(const QString &text); + void callSetUserSelection(bool); + +public Q_SLOTS: + /** + * Moves the cursor at the end of the line edit. + */ + void cursorAtEnd(); + + /** + * Sets whether autocompletion shall be enabled. + */ + void enableCompletion(bool enable); + + /** + * Reimplemented for stripping whitespace after completion + * Danger: This is _not_ virtual in the base class! + */ + void setText(const QString &text) override; + + void emitTextCompleted(); + + void slotEditingFinished(); + +Q_SIGNALS: + void textCompleted(); + +protected: + /** + * Reimplemented for smart insertion of email addresses. + * Features: + * - Automatically adds ',' if necessary to separate email addresses + * - Correctly decodes mailto URLs + * - Recognizes email addresses which are protected against address + * harvesters, i.e. "name at kde dot org" and "name(at)kde.org" + */ + virtual void insert(const QString &); + + /** + * Reimplemented for smart insertion of pasted email addresses. + */ + virtual void paste(); + + /** + * Reimplemented for smart insertion with middle mouse button. + */ + void mouseReleaseEvent(QMouseEvent *) override; + + /** + * Reimplemented for internal reasons. + */ + void keyPressEvent(QKeyEvent *) override; + + bool eventFilter(QObject *object, QEvent *event) override; + + void insertEmails(const QStringList &emails); + void dropEvent(QDropEvent *event) override; + +private: + void addContact(const QStringList &emails, const KContacts::Addressee &addr, int weight, int source, QString append = QString()); + + std::unique_ptr d; +}; diff --git a/server/editor/addresseelineeditmanager.cpp b/server/editor/addresseelineeditmanager.cpp new file mode 100644 index 0000000..6dfaadd --- /dev/null +++ b/server/editor/addresseelineeditmanager.cpp @@ -0,0 +1,100 @@ +/* + SPDX-FileCopyrightText: 2015-2024 Laurent Montel + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#include "addresseelineeditmanager.h" +#include "addresseelineeditgnupg.h" +#include "kmailcompletion.h" + +#include "editor_debug.h" +#include +#include +#include +#include +#include +#include +#include + +Q_GLOBAL_STATIC(AddresseeLineEditManager, sInstance) + +AddresseeLineEditManager::AddresseeLineEditManager() + : mCompletion(std::make_unique()) + //, mAddresseeLineEditGnupg(new AddresseeLineEditGnupg) +{ + KConfigGroup group(KSharedConfig::openConfig(), QStringLiteral("AddressLineEdit")); + mAutoGroupExpand = group.readEntry("AutoGroupExpand", false); +} + +AddresseeLineEditManager::~AddresseeLineEditManager() = default; + +AddresseeLineEditManager *AddresseeLineEditManager::self() +{ + return sInstance; +} + +int AddresseeLineEditManager::addCompletionSource(const QString &source, int weight) +{ + QMap::iterator it = completionSourceWeights.find(source); + if (it == completionSourceWeights.end()) { + completionSourceWeights.insert(source, weight); + } else { + completionSourceWeights[source] = weight; + } + + const int sourceIndex = completionSources.indexOf(source); + if (sourceIndex == -1) { + completionSources.append(source); + return completionSources.size() - 1; + } else { + return sourceIndex; + } +} + +void AddresseeLineEditManager::removeCompletionSource(const QString &source) +{ + QMap::iterator it = completionSourceWeights.find(source); + if (it != completionSourceWeights.end()) { + completionSourceWeights.remove(source); + mCompletion->clear(); + } +} + +KMailCompletion *AddresseeLineEditManager::completion() const +{ + return mCompletion.get(); +} + +bool AddresseeLineEditManager::isOnline() const +{ + if (QNetworkInformation::loadBackendByFeatures(QNetworkInformation::Feature::Reachability)) { + return QNetworkInformation::instance()->reachability() == QNetworkInformation::Reachability::Online; + } else { + qCWarning(EDITOR_LOG) << "Couldn't find a working backend for QNetworkInformation"; + return false; + } +} + +bool AddresseeLineEditManager::autoGroupExpand() const +{ + return mAutoGroupExpand; +} + +void AddresseeLineEditManager::setAutoGroupExpand(bool checked) +{ + if (mAutoGroupExpand != checked) { + mAutoGroupExpand = checked; + KConfigGroup group(KSharedConfig::openConfig(), QStringLiteral("AddressLineEdit")); + group.writeEntry("AutoGroupExpand", mAutoGroupExpand); + } +} + +QColor AddresseeLineEditManager::alternateColor() const +{ + if (!mAlternateColor.isValid()) { + const KColorScheme colorScheme(QPalette::Active, KColorScheme::View); + mAlternateColor = colorScheme.background(KColorScheme::AlternateBackground).color(); + } + return mAlternateColor; +} diff --git a/server/editor/addresseelineeditmanager.h b/server/editor/addresseelineeditmanager.h new file mode 100644 index 0000000..0ea26f0 --- /dev/null +++ b/server/editor/addresseelineeditmanager.h @@ -0,0 +1,80 @@ +/* + SPDX-FileCopyrightText: 2015-2024 Laurent Montel + + SPDX-License-Identifier: GPL-2.0-or-later +*/ + +#pragma once + +#include + +#include +#include + +#include + +class QTimer; +class QNetworkConfigurationManager; +class KMailCompletion; +class AddresseeLineEdit; +class AddresseeLineEditGnupg; + +class AddresseeLineEditManager +{ +public: + using CompletionItemsMap = QMap>; + + AddresseeLineEditManager(); + + ~AddresseeLineEditManager(); + + static AddresseeLineEditManager *self(); + + int addCompletionSource(const QString &source, int weight); + void removeCompletionSource(const QString &source); + + CompletionItemsMap completionItemsMap; + QStringList completionSources; + + // The weights associated with the completion sources in s_static->completionSources. + // Both are maintained by addCompletionSource(), don't attempt to modify those yourself. + QMap completionSourceWeights; + + // holds the cached mapping from akonadi collection id to the completion source index + struct collectionInfo { + collectionInfo() + : index(-1) + , enabled(true) + { + } + + collectionInfo(int idx, bool _enabled) + : index(idx) + , enabled(_enabled) + { + } + + int index; + bool enabled; + }; + + KMailCompletion *completion() const; + + [[nodiscard]] bool isOnline() const; + + void loadBalooBlackList(); + [[nodiscard]] QStringList cleanupEmailList(const QStringList &inputList); + [[nodiscard]] QStringList balooBlackList() const; + + bool autoGroupExpand() const; + void setAutoGroupExpand(bool checked); + + [[nodiscard]] QColor alternateColor() const; + +private: + QStringList mRecentAddressEmailList; + QStringList mRecentCleanupAddressEmailList; + mutable QColor mAlternateColor; + bool mAutoGroupExpand = false; + std::unique_ptr const mCompletion; +}; diff --git a/server/editor/recipientline.cpp b/server/editor/recipientline.cpp index 57486a7..2f35943 100644 --- a/server/editor/recipientline.cpp +++ b/server/editor/recipientline.cpp @@ -1,301 +1,306 @@ /* SPDX-FileCopyrightText: 2010 Casey Link SPDX-FileCopyrightText: 2009-2010 Klaralvdalens Datakonsult AB, a KDAB Group company SPDX-License-Identifier: LGPL-2.0-or-later */ #include "recipientline.h" #include #include #include #include #include #include using namespace KPIM; using namespace Qt::Literals::StringLiterals; RecipientComboBox::RecipientComboBox(QWidget *parent) : QComboBox(parent) { } void RecipientComboBox::keyPressEvent(QKeyEvent *ev) { if (ev->key() == Qt::Key_Right) { Q_EMIT rightPressed(); } else { QComboBox::keyPressEvent(ev); } } RecipientLineEdit::RecipientLineEdit(QWidget *parent) - : KLineEdit(parent) + : AddresseeLineEdit(parent) , mToolButton(new QToolButton(this)) { mToolButton->setVisible(false); mToolButton->setCursor(Qt::ArrowCursor); const int size = sizeHint().height() - 5; mToolButton->setFixedSize(size, size); int padding = (sizeHint().height() - size) / 2; mToolButton->move(2, padding); mToolButton->setStyleSheet(QStringLiteral("QToolButton { border: none; }")); connect(mToolButton, &QToolButton::clicked, this, &RecipientLineEdit::iconClicked); } void RecipientLineEdit::keyPressEvent(QKeyEvent *ev) { - // Laurent Bug:280153 - /*if ( ev->key() == Qt::Key_Backspace && text().isEmpty() ) { - ev->accept(); - Q_EMIT deleteMe(); - } else */ if (ev->key() == Qt::Key_Left && cursorPosition() == 0 && !ev->modifiers().testFlag(Qt::ShiftModifier)) { // Shift would be pressed during selection Q_EMIT leftPressed(); } else if (ev->key() == Qt::Key_Right && cursorPosition() == text().length() && !ev->modifiers().testFlag(Qt::ShiftModifier)) { // Shift would be pressed during selection Q_EMIT rightPressed(); + } else if ((ev->key() == Qt::Key_Enter || ev->key() == Qt::Key_Return)) {// && !completionBox()->isVisible()) { + Q_EMIT focusDown(); + KLineEdit::keyPressEvent(ev); + return; + } else if (ev->key() == Qt::Key_Up) { + Q_EMIT focusUp(); + return; + } else if (ev->key() == Qt::Key_Down) { + Q_EMIT focusDown(); + return; } else { KLineEdit::keyPressEvent(ev); } } void RecipientLineEdit::setIcon(const QIcon &icon, const QString &tooltip) { if (icon.isNull()) { mToolButton->setVisible(false); setStyleSheet(QString()); } else { mToolButton->setIcon(icon); mToolButton->setToolTip(tooltip); const int padding = mToolButton->width() - style()->pixelMetric(QStyle::PM_DefaultFrameWidth); setStyleSheet(QStringLiteral("QLineEdit { padding-left: %1px }").arg(padding)); mToolButton->setVisible(true); } } RecipientLineNG::RecipientLineNG(QWidget *parent) : MultiplyingLine(parent) , mData(new Recipient) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); auto topLayout = new QHBoxLayout(this); topLayout->setContentsMargins({}); const QStringList recipientTypes = Recipient::allTypeLabels(); mCombo = new RecipientComboBox(this); mCombo->addItems(recipientTypes); topLayout->addWidget(mCombo); mCombo->setToolTip(i18nc("@label:listbox", "Select type of recipient")); mEdit = new RecipientLineEdit(this); mEdit->setToolTip(i18n("Set the list of email addresses to receive this message")); mEdit->setClearButtonEnabled(true); topLayout->addWidget(mEdit); mEdit->installEventFilter(this); connect(mEdit, &RecipientLineEdit::returnPressed, this, &RecipientLineNG::slotReturnPressed); connect(mEdit, &RecipientLineEdit::deleteMe, this, &RecipientLineNG::slotPropagateDeletion); connect(mEdit, &QLineEdit::textChanged, this, &RecipientLineNG::analyzeLine); connect(mEdit, &RecipientLineEdit::focusUp, this, &RecipientLineNG::slotFocusUp); connect(mEdit, &RecipientLineEdit::focusDown, this, &RecipientLineNG::slotFocusDown); connect(mEdit, &RecipientLineEdit::rightPressed, this, &RecipientLineNG::rightPressed); connect(mEdit, &RecipientLineEdit::iconClicked, this, &RecipientLineNG::iconClicked); connect(mEdit, &RecipientLineEdit::leftPressed, mCombo, qOverload<>(&QWidget::setFocus)); connect(mEdit, &RecipientLineEdit::editingFinished, this, &RecipientLineNG::slotEditingFinished); connect(mEdit, &RecipientLineEdit::clearButtonClicked, this, &RecipientLineNG::slotPropagateDeletion); connect(mCombo, &RecipientComboBox::rightPressed, mEdit, qOverload<>(&QWidget::setFocus)); connect(mCombo, &RecipientComboBox::activated, this, &RecipientLineNG::slotTypeModified); connect(mEdit, &RecipientLineEdit::addAddress, this, &RecipientLineNG::slotAddRecipient); } void RecipientLineNG::slotEditingFinished() { if (mEdit->text().isEmpty()) { Q_EMIT deleteLine(this); } } void RecipientLineNG::slotAddRecipient(const QString &email) { Q_EMIT addRecipient(this, email); slotReturnPressed(); } void RecipientLineNG::slotTypeModified() { mModified = true; Q_EMIT typeModified(this); } void RecipientLineNG::analyzeLine(const QString &text) { const QStringList r = KEmailAddress::splitAddressList(text); mRecipientsCount = r.count(); mModified = true; Q_EMIT countChanged(); } int RecipientLineNG::recipientsCount() const { return mRecipientsCount; } void RecipientLineNG::setData(const MultiplyingLineData::Ptr &data) { Recipient::Ptr rec = qSharedPointerDynamicCast(data); if (rec.isNull()) { return; } mData = rec; fieldsFromData(); } MultiplyingLineData::Ptr RecipientLineNG::data() const { if (isModified()) { const_cast(this)->dataFromFields(); } return mData; } void RecipientLineNG::dataFromFields() { if (!mData) { return; } const QString editStr(mEdit->text()); QString displayName; QString addrSpec; QString comment; if (KEmailAddress::splitAddress(editStr, displayName, addrSpec, comment) == KEmailAddress::AddressOk) { mData->setName(displayName); } KMime::Types::Mailbox mbox; mbox.from7BitString(editStr.toUtf8()); if (mbox.hasAddress()) { mData->setEmail(mbox.addrSpec().asString()); mData->setName(mbox.name()); } else { mData->setEmail(editStr); } mData->setType(Recipient::idToType(mCombo->currentIndex())); mModified = false; } void RecipientLineNG::fieldsFromData() { if (!mData) { return; } mCombo->setCurrentIndex(Recipient::typeToId(mData->type())); mEdit->setText(mData->name().isEmpty() ? mData->email() : mData->name() + u" <"_s +mData->email() + u'>'); } void RecipientLineNG::activate() { mEdit->setFocus(); } bool RecipientLineNG::isActive() const { return mEdit->hasFocus(); } bool RecipientLineNG::isEmpty() const { return mEdit->text().isEmpty(); } bool RecipientLineNG::isModified() const { return mModified || mEdit->isModified(); } void RecipientLineNG::clearModified() { mModified = false; mEdit->setModified(false); } int RecipientLineNG::setColumnWidth(int w) { w = qMax(w, mCombo->sizeHint().width()); mCombo->setFixedWidth(w); mCombo->updateGeometry(); parentWidget()->updateGeometry(); return w; } void RecipientLineNG::fixTabOrder(QWidget *previous) { setTabOrder(previous, mCombo); setTabOrder(mCombo, mEdit); } QWidget *RecipientLineNG::tabOut() const { return mEdit; } void RecipientLineNG::clear() { mRecipientsCount = 0; mEdit->clear(); } bool RecipientLineNG::canDeleteLineEdit() const { return true; } void RecipientLineNG::setCompletionMode(KCompletion::CompletionMode mode) { mEdit->setCompletionMode(mode); } Recipient::Type RecipientLineNG::recipientType() const { return Recipient::idToType(mCombo->currentIndex()); } void RecipientLineNG::setRecipientType(Recipient::Type type) { mCombo->setCurrentIndex(Recipient::typeToId(type)); slotTypeModified(); } Recipient::Ptr RecipientLineNG::recipient() const { return qSharedPointerDynamicCast(data()); } void RecipientLineNG::setIcon(const QIcon &icon, const QString &tooltip) { mEdit->setIcon(icon, tooltip); } QString RecipientLineNG::rawData() const { return mEdit->text(); } bool RecipientLineNG::eventFilter(QObject *watched, QEvent *event) { if (watched == mEdit) { if (event->type() == QEvent::FocusIn || event->type() == QEvent::FocusOut) { Q_EMIT activeChanged(); } } return false; } diff --git a/server/editor/recipientline.h b/server/editor/recipientline.h index 10399a8..a347aff 100644 --- a/server/editor/recipientline.h +++ b/server/editor/recipientline.h @@ -1,126 +1,127 @@ /* SPDX-FileCopyrightText: 2010 Casey Link SPDX-FileCopyrightText: 2009-2010 Klaralvdalens Datakonsult AB, a KDAB Group company SPDX-License-Identifier: LGPL-2.0-or-later */ #pragma once #include #include #include #include #include "recipient.h" +#include "addresseelineedit.h" class KConfig; class RecipientComboBox : public QComboBox { Q_OBJECT public: explicit RecipientComboBox(QWidget *parent); Q_SIGNALS: void rightPressed(); protected: void keyPressEvent(QKeyEvent *ev) override; }; /** * @brief The RecipientLineEdit class */ -class RecipientLineEdit : public KLineEdit +class RecipientLineEdit : public AddresseeLineEdit { Q_OBJECT public: explicit RecipientLineEdit(QWidget *parent); void setIcon(const QIcon &icon, const QString &tooltip); Q_SIGNALS: void addAddress(const QString &address); void iconClicked(); void focusUp(); void focusDown(); void deleteMe(); void leftPressed(); void rightPressed(); protected: void keyPressEvent(QKeyEvent *ev) override; private: QToolButton *const mToolButton; }; /** * @brief The RecipientLineNG class */ class RecipientLineNG : public KPIM::MultiplyingLine { Q_OBJECT public: explicit RecipientLineNG(QWidget *parent); ~RecipientLineNG() override = default; void activate() override; [[nodiscard]] bool isActive() const override; [[nodiscard]] bool isEmpty() const override; void clear() override; [[nodiscard]] bool canDeleteLineEdit() const override; [[nodiscard]] bool isModified() const override; void clearModified() override; [[nodiscard]] KPIM::MultiplyingLineData::Ptr data() const override; void setData(const KPIM::MultiplyingLineData::Ptr &data) override; void fixTabOrder(QWidget *previous) override; [[nodiscard]] QWidget *tabOut() const override; void setCompletionMode(KCompletion::CompletionMode mode) override; [[nodiscard]] int setColumnWidth(int w) override; // recipient specific methods [[nodiscard]] int recipientsCount() const; void setRecipientType(Recipient::Type); [[nodiscard]] Recipient::Type recipientType() const; [[nodiscard]] QSharedPointer recipient() const; void setIcon(const QIcon &icon, const QString &tooltip = QString()); [[nodiscard]] QString rawData() const; Q_SIGNALS: void typeModified(RecipientLineNG *); void addRecipient(RecipientLineNG *, const QString &); void countChanged(); void iconClicked(); void activeChanged(); protected Q_SLOTS: void slotEditingFinished(); void slotTypeModified(); void analyzeLine(const QString &); protected: bool eventFilter(QObject *watched, QEvent *event) override; private: void dataFromFields(); void fieldsFromData(); void slotAddRecipient(const QString &); RecipientComboBox *mCombo = nullptr; RecipientLineEdit *mEdit = nullptr; int mRecipientsCount = 0; bool mModified = false; QSharedPointer mData; };