File indexing completed on 2024-12-08 07:34:30

0001 /*
0002    SPDX-FileCopyrightText: 2018-2024 Laurent Montel <montel@kde.org>
0003 
0004    SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "textconverter.h"
0008 #include "colorsandmessageviewstyle.h"
0009 #include "emoticons/emojimanager.h"
0010 #include "messagecache.h"
0011 #include "messages/message.h"
0012 #include "ruqola_texttohtml_debug.h"
0013 #include "utils.h"
0014 
0015 #include "ktexttohtmlfork/ruqolaktexttohtml.h"
0016 #include "syntaxhighlightingmanager.h"
0017 #include "texthighlighter.h"
0018 #include <KSyntaxHighlighting/Definition>
0019 #include <KSyntaxHighlighting/Repository>
0020 #include <KSyntaxHighlighting/Theme>
0021 
0022 #include <KColorScheme>
0023 namespace
0024 {
0025 /// check if the @p str contains an uneven number of backslashes before @p pos
0026 bool isEscaped(const QString &str, int pos)
0027 {
0028     int backslashes = 0;
0029     while (pos > 0 && str[pos - 1] == QLatin1Char('\\')) {
0030         ++backslashes;
0031         --pos;
0032     }
0033     // even number of escapes means the
0034     return backslashes % 2 == 1;
0035 }
0036 
0037 int findNonEscaped(const QString &str, const QString &regionMarker, int startFrom)
0038 {
0039     while (true) {
0040         const int index = str.indexOf(regionMarker, startFrom);
0041         if (index == -1) {
0042             return -1;
0043         } else if (isEscaped(str, index)) {
0044             startFrom = index + regionMarker.size();
0045             continue;
0046         }
0047         return index;
0048     }
0049     Q_UNREACHABLE();
0050 }
0051 int findNewLineOrEndLine(const QString &str, const QString &regionMarker, int startFrom)
0052 {
0053     const int index = str.indexOf(regionMarker, startFrom);
0054     if (index == -1) {
0055         return str.length() - 1;
0056     } else {
0057         return index;
0058     }
0059     Q_UNREACHABLE();
0060 }
0061 
0062 template<typename InRegionCallback, typename OutsideRegionCallback>
0063 void iterateOverRegions(const QString &str, const QString &regionMarker, InRegionCallback &&inRegion, OutsideRegionCallback &&outsideRegion)
0064 {
0065     int startFrom = 0;
0066     const auto markerSize = regionMarker.size();
0067     while (true) {
0068         const int startIndex = findNonEscaped(str, regionMarker, startFrom);
0069         if (startIndex == -1) {
0070             break;
0071         }
0072 
0073         const int endIndex = findNonEscaped(str, regionMarker, startIndex + markerSize);
0074         if (endIndex == -1) {
0075             break;
0076         }
0077 
0078         const auto codeBlock = str.mid(startIndex + markerSize, endIndex - startIndex - markerSize).trimmed();
0079 
0080         outsideRegion(str.mid(startFrom, startIndex - startFrom));
0081         startFrom = endIndex + markerSize;
0082 
0083         inRegion(codeBlock);
0084     }
0085     outsideRegion(str.mid(startFrom));
0086 }
0087 
0088 template<typename InRegionCallback, typename OutsideRegionCallback, typename NewLineCallBack>
0089 void iterateOverEndLineRegions(const QString &str,
0090                                const QString &regionMarker,
0091                                InRegionCallback &&inRegion,
0092                                OutsideRegionCallback &&outsideRegion,
0093                                NewLineCallBack &&newLine)
0094 {
0095     // We have quote text if text start with > or we have "\n>"
0096     if (str.startsWith(regionMarker) || str.contains(QStringLiteral("\n") + regionMarker)) {
0097         int startFrom = 0;
0098         const auto markerSize = regionMarker.size();
0099         bool hasCode = false;
0100         while (true) {
0101             const int startIndex = findNonEscaped(str, regionMarker, startFrom);
0102             if (startIndex == -1) {
0103                 break;
0104             }
0105 
0106             const int endIndex = findNewLineOrEndLine(str, QStringLiteral("\n"), startIndex + markerSize);
0107             if (endIndex == -1) {
0108                 break;
0109             }
0110             QStringView codeBlock = QStringView(str).mid(startIndex + markerSize, endIndex - startIndex).trimmed();
0111             if (codeBlock.endsWith(regionMarker)) {
0112                 codeBlock.chop(regionMarker.size());
0113             }
0114             if (hasCode) {
0115                 newLine();
0116             }
0117             const QStringView midCode = QStringView(str).mid(startFrom, startIndex - startFrom);
0118             outsideRegion(midCode.toString());
0119             startFrom = endIndex + markerSize;
0120 
0121             inRegion(codeBlock.toString());
0122             if (!codeBlock.isEmpty()) {
0123                 hasCode = true;
0124             }
0125         }
0126         const QString afterstr = str.mid(startFrom);
0127         outsideRegion(afterstr);
0128     } else {
0129         outsideRegion(str);
0130     }
0131 }
0132 
0133 QString markdownToRichText(const QString &markDown)
0134 {
0135     if (markDown.isEmpty()) {
0136         return {};
0137     }
0138 
0139     qCDebug(RUQOLA_TEXTTOHTML_LOG) << "BEFORE markdownToRichText " << markDown;
0140     QString str = markDown;
0141 
0142     const RuqolaKTextToHTML::Options convertFlags = RuqolaKTextToHTML::HighlightText | RuqolaKTextToHTML::ConvertPhoneNumbers;
0143     str = RuqolaKTextToHTML::convertToHtml(str, convertFlags);
0144     qCDebug(RUQOLA_TEXTTOHTML_LOG) << " AFTER convertToHtml " << str;
0145     // substitute "[example.com](<a href="...">...</a>)" style urls
0146     str = Utils::convertTextWithUrl(str);
0147     // Substiture "- [ ] foo" and "- [x] foo" to checkmark
0148     str = Utils::convertTextWithCheckMark(str);
0149     // Substiture # header
0150     str = Utils::convertTextHeaders(str);
0151     qCDebug(RUQOLA_TEXTTOHTML_LOG) << " AFTER convertTextWithUrl " << str;
0152 
0153     return str;
0154 }
0155 
0156 QString generateRichText(const QString &str,
0157                          const QString &username,
0158                          const QStringList &highlightWords,
0159                          const QMap<QString, QString> &mentions,
0160                          const QMap<QString, QString> &channels,
0161                          const QString &searchedText)
0162 {
0163     QString newStr = markdownToRichText(str);
0164     static const QRegularExpression regularExpressionAHref(QStringLiteral("(<a href=\'.*\'>|<a href=\".*\">)"));
0165     struct HrefPos {
0166         int start = 0;
0167         int end = 0;
0168     };
0169     QList<HrefPos> lstPos;
0170     {
0171         QRegularExpressionMatchIterator userIteratorHref = regularExpressionAHref.globalMatch(newStr);
0172         while (userIteratorHref.hasNext()) {
0173             const QRegularExpressionMatch match = userIteratorHref.next();
0174             HrefPos pos;
0175             pos.start = match.capturedStart(1);
0176             pos.end = match.capturedEnd(1);
0177             lstPos.append(std::move(pos));
0178         }
0179 
0180         static const QRegularExpression regularExpressionRoom(QStringLiteral("(^|\\s+)#([\\w._-]+)"), QRegularExpression::UseUnicodePropertiesOption);
0181         QRegularExpressionMatchIterator roomIterator = regularExpressionRoom.globalMatch(newStr);
0182         while (roomIterator.hasNext()) {
0183             const QRegularExpressionMatch match = roomIterator.next();
0184             const QStringView word = match.capturedView(2);
0185             bool inAnUrl = false;
0186             const int matchCapturedStart = match.capturedStart(2);
0187             for (const HrefPos &hrefPos : lstPos) {
0188                 if ((matchCapturedStart > hrefPos.start) && (matchCapturedStart < hrefPos.end)) {
0189                     inAnUrl = true;
0190                     break;
0191                 }
0192             }
0193             if (inAnUrl) {
0194                 continue;
0195             }
0196             QString roomIdentifier = channels.value(word.toString());
0197             if (roomIdentifier.isEmpty()) {
0198                 roomIdentifier = word.toString();
0199             }
0200             newStr.replace(QLatin1Char('#') + word.toString(), QStringLiteral("<a href=\'ruqola:/room/%2\'>#%1</a>").arg(word, roomIdentifier));
0201         }
0202     }
0203 
0204     if (!highlightWords.isEmpty()) {
0205         const auto userHighlightForegroundColor = ColorsAndMessageViewStyle::self().schemeView().foreground(KColorScheme::PositiveText).color().name();
0206         const auto userHighlightBackgroundColor = ColorsAndMessageViewStyle::self().schemeView().background(KColorScheme::PositiveBackground).color().name();
0207         lstPos.clear();
0208         QRegularExpressionMatchIterator userIteratorHref = regularExpressionAHref.globalMatch(newStr);
0209         while (userIteratorHref.hasNext()) {
0210             const QRegularExpressionMatch match = userIteratorHref.next();
0211             HrefPos pos;
0212             pos.start = match.capturedStart(1);
0213             pos.end = match.capturedEnd(1);
0214             lstPos.append(std::move(pos));
0215         }
0216 
0217         for (const QString &word : highlightWords) {
0218             const QRegularExpression exp(QStringLiteral("(\\b%1\\b)").arg(word), QRegularExpression::CaseInsensitiveOption);
0219             QRegularExpressionMatchIterator userIterator = exp.globalMatch(newStr);
0220             int offset = 0;
0221             while (userIterator.hasNext()) {
0222                 const QRegularExpressionMatch match = userIterator.next();
0223                 const QString word = match.captured(1);
0224                 bool inAnUrl = false;
0225                 const int matchCapturedStart = match.capturedStart(1);
0226                 for (const HrefPos &hrefPos : lstPos) {
0227                     if ((matchCapturedStart > hrefPos.start) && (matchCapturedStart < hrefPos.end)) {
0228                         inAnUrl = true;
0229                         break;
0230                     }
0231                 }
0232                 if (inAnUrl) {
0233                     continue;
0234                 }
0235                 const QString replaceStr =
0236                     QStringLiteral("<a style=\"color:%2;background-color:%3;\">%1</a>").arg(word, userHighlightForegroundColor, userHighlightBackgroundColor);
0237                 newStr.replace(matchCapturedStart + offset, word.length(), replaceStr);
0238                 // We added a new string => increase offset
0239                 offset += replaceStr.length() - word.length();
0240             }
0241         }
0242     }
0243 
0244     if (!searchedText.isEmpty()) {
0245         const auto userHighlightForegroundColor = ColorsAndMessageViewStyle::self().schemeView().foreground(KColorScheme::NeutralText).color().name();
0246         const auto userHighlightBackgroundColor = ColorsAndMessageViewStyle::self().schemeView().background(KColorScheme::NeutralBackground).color().name();
0247         lstPos.clear();
0248         QRegularExpressionMatchIterator userIteratorHref = regularExpressionAHref.globalMatch(newStr);
0249         while (userIteratorHref.hasNext()) {
0250             const QRegularExpressionMatch match = userIteratorHref.next();
0251             HrefPos pos;
0252             pos.start = match.capturedStart(1);
0253             pos.end = match.capturedEnd(1);
0254             lstPos.append(std::move(pos));
0255         }
0256 
0257         const QRegularExpression exp(QStringLiteral("(%1)").arg(searchedText), QRegularExpression::CaseInsensitiveOption);
0258         QRegularExpressionMatchIterator userIterator = exp.globalMatch(newStr);
0259         int offset = 0;
0260         while (userIterator.hasNext()) {
0261             const QRegularExpressionMatch match = userIterator.next();
0262             const QString word = match.captured(1);
0263             bool inAnUrl = false;
0264             const int matchCapturedStart = match.capturedStart(1);
0265             for (const HrefPos &hrefPos : lstPos) {
0266                 if ((matchCapturedStart > hrefPos.start) && (matchCapturedStart < hrefPos.end)) {
0267                     inAnUrl = true;
0268                     break;
0269                 }
0270             }
0271             if (inAnUrl) {
0272                 continue;
0273             }
0274             const QString replaceStr =
0275                 QStringLiteral("<a style=\"color:%2;background-color:%3;\">%1</a>").arg(word, userHighlightForegroundColor, userHighlightBackgroundColor);
0276             newStr.replace(matchCapturedStart + offset, word.length(), replaceStr);
0277             // We added a new string => increase offset
0278             offset += replaceStr.length() - word.length();
0279         }
0280     }
0281     static const QRegularExpression regularExpressionUser(QStringLiteral("(^|\\s+)@([\\w._-]+)"), QRegularExpression::UseUnicodePropertiesOption);
0282     QRegularExpressionMatchIterator userIterator = regularExpressionUser.globalMatch(newStr);
0283 
0284     const auto userMentionForegroundColor = ColorsAndMessageViewStyle::self().schemeView().foreground(KColorScheme::NegativeText).color().name();
0285     const auto userMentionBackgroundColor = ColorsAndMessageViewStyle::self().schemeView().background(KColorScheme::NegativeBackground).color().name();
0286     while (userIterator.hasNext()) {
0287         const QRegularExpressionMatch match = userIterator.next();
0288         const QStringView word = match.capturedView(2);
0289         // Highlight only if it's yours
0290 
0291         QString userIdentifier = mentions.value(word.toString());
0292         if (userIdentifier.isEmpty()) {
0293             userIdentifier = word.toString();
0294         }
0295         if (word == username) {
0296             newStr.replace(QLatin1Char('@') + word.toString(),
0297                            QStringLiteral("<a href=\'ruqola:/user/%4\' style=\"color:%2;background-color:%3;font-weight:bold\">@%1</a>")
0298                                .arg(word.toString(), userMentionForegroundColor, userMentionBackgroundColor, userIdentifier));
0299 
0300         } else {
0301             newStr.replace(QLatin1Char('@') + word.toString(), QStringLiteral("<a href=\'ruqola:/user/%2\'>@%1</a>").arg(word, userIdentifier));
0302         }
0303     }
0304 
0305     return newStr;
0306 }
0307 }
0308 
0309 QString TextConverter::convertMessageText(const ConvertMessageTextSettings &settings, QString &needUpdateMessageId, int &recusiveIndex)
0310 {
0311     if (!settings.emojiManager) {
0312         qCWarning(RUQOLA_TEXTTOHTML_LOG) << "Emojimanager is null";
0313     }
0314 
0315     QString quotedMessage;
0316 
0317     QString str = settings.str;
0318     // TODO we need to look at room name too as we can have it when we use "direct reply"
0319     if (str.contains(QLatin1String("[ ](http"))
0320         && (settings.maximumRecursiveQuotedText == -1 || (settings.maximumRecursiveQuotedText > recusiveIndex))) { // ## is there a better way?
0321         const int startPos = str.indexOf(QLatin1Char('('));
0322         const int endPos = str.indexOf(QLatin1Char(')'));
0323         const QString url = str.mid(startPos + 1, endPos - startPos - 1);
0324         // URL example https://HOSTNAME/channel/all?msg=3BR34NSG5x7ZfBa22
0325         const QString messageId = url.mid(url.indexOf(QLatin1String("msg=")) + 4);
0326         // qCDebug(RUQOLA_TEXTTOHTML_LOG) << "Extracted messageId" << messageId;
0327         auto it = std::find_if(settings.allMessages.cbegin(), settings.allMessages.cend(), [messageId](const Message &msg) {
0328             return msg.messageId() == messageId;
0329         });
0330         if (it != settings.allMessages.cend()) {
0331             const ConvertMessageTextSettings newSetting(QLatin1Char('@') + (*it).username() + QStringLiteral(": ") + (*it).text(),
0332                                                         settings.userName,
0333                                                         settings.allMessages,
0334                                                         settings.highlightWords,
0335                                                         settings.emojiManager,
0336                                                         settings.messageCache,
0337                                                         (*it).mentions(),
0338                                                         (*it).channels(),
0339                                                         settings.searchedText,
0340                                                         settings.maximumRecursiveQuotedText);
0341             recusiveIndex++;
0342             const QString text = convertMessageText(newSetting, needUpdateMessageId, recusiveIndex);
0343             Utils::QuotedRichTextInfo info;
0344             info.url = url;
0345             info.richText = text;
0346             info.displayTime = (*it).dateTime();
0347             quotedMessage = Utils::formatQuotedRichText(std::move(info));
0348             str = str.left(startPos - 3) + str.mid(endPos + 1);
0349         } else {
0350             if (settings.messageCache) {
0351                 // TODO allow to reload index when we loaded message
0352                 Message *msg = settings.messageCache->messageForId(messageId);
0353                 if (msg) {
0354                     const ConvertMessageTextSettings newSetting(msg->text(),
0355                                                                 settings.userName,
0356                                                                 settings.allMessages,
0357                                                                 settings.highlightWords,
0358                                                                 settings.emojiManager,
0359                                                                 settings.messageCache,
0360                                                                 msg->mentions(),
0361                                                                 msg->channels(),
0362                                                                 settings.searchedText,
0363                                                                 settings.maximumRecursiveQuotedText);
0364                     recusiveIndex++;
0365                     const QString text = convertMessageText(newSetting, needUpdateMessageId, recusiveIndex);
0366                     Utils::QuotedRichTextInfo info;
0367                     info.url = url;
0368                     info.richText = text;
0369                     info.displayTime = msg->dateTime();
0370                     quotedMessage = Utils::formatQuotedRichText(std::move(info));
0371                     str = str.left(startPos - 3) + str.mid(endPos + 1);
0372                 } else {
0373                     qCDebug(RUQOLA_TEXTTOHTML_LOG) << "Quoted message" << messageId << "not found"; // could be a very old one
0374                     needUpdateMessageId = messageId;
0375                 }
0376             }
0377         }
0378     }
0379 
0380     QString richText;
0381     QTextStream richTextStream(&richText);
0382     const auto codeBackgroundColor = ColorsAndMessageViewStyle::self().schemeView().background(KColorScheme::AlternateBackground).color();
0383     const auto codeBorderColor = ColorsAndMessageViewStyle::self().schemeView().foreground(KColorScheme::InactiveText).color().name();
0384 
0385     QString highlighted;
0386     QTextStream stream(&highlighted);
0387     TextHighlighter highlighter(&stream);
0388     const auto useHighlighter = SyntaxHighlightingManager::self()->syntaxHighlightingInitialized();
0389 
0390     if (useHighlighter) {
0391         auto &repo = SyntaxHighlightingManager::self()->repo();
0392         highlighter.setTheme(codeBackgroundColor.lightness() < 128 ? repo.defaultTheme(KSyntaxHighlighting::Repository::DarkTheme)
0393                                                                    : repo.defaultTheme(KSyntaxHighlighting::Repository::LightTheme));
0394     }
0395     auto highlight = [&](const QString &codeBlock) {
0396         if (!useHighlighter) {
0397             return codeBlock;
0398         }
0399         stream.reset();
0400         stream.seek(0);
0401         highlighted.clear();
0402         highlighter.highlight(codeBlock);
0403         return highlighted;
0404     };
0405 
0406     auto addCodeChunk = [&](QString chunk) {
0407         const auto language = [&]() {
0408             const auto newline = chunk.indexOf(QLatin1Char('\n'));
0409             if (newline == -1) {
0410                 return QString();
0411             }
0412             return chunk.left(newline);
0413         }();
0414 
0415         auto definition = SyntaxHighlightingManager::self()->def(language);
0416         if (definition.isValid()) {
0417             chunk.remove(0, language.size() + 1);
0418         } else {
0419             definition = SyntaxHighlightingManager::self()->defaultDef();
0420         }
0421 
0422         highlighter.setDefinition(std::move(definition));
0423         // Qt's support for borders is limited to tables, so we have to jump through some hoops...
0424         richTextStream << QLatin1String("<table><tr><td style='background-color:") << codeBackgroundColor.name()
0425                        << QLatin1String("; padding: 5px; border: 1px solid ") << codeBorderColor << QLatin1String("'>") << highlight(chunk)
0426                        << QLatin1String("</td></tr></table>");
0427     };
0428 
0429     auto addInlineCodeChunk = [&](const QString &chunk) {
0430         richTextStream << QLatin1String("<code style='background-color:") << codeBackgroundColor.name() << QLatin1String("'>") << chunk.toHtmlEscaped()
0431                        << QLatin1String("</code>");
0432     };
0433 
0434     auto addTextChunk = [&](const QString &chunk) {
0435         auto htmlChunk = generateRichText(chunk, settings.userName, settings.highlightWords, settings.mentions, settings.channels, settings.searchedText);
0436         if (settings.emojiManager) {
0437             settings.emojiManager->replaceEmojis(&htmlChunk);
0438         }
0439         richTextStream << htmlChunk;
0440     };
0441     auto addInlineQuoteCodeChunk = [&](const QString &chunk) {
0442         auto htmlChunk = generateRichText(chunk, settings.userName, settings.highlightWords, settings.mentions, settings.channels, settings.searchedText);
0443         if (settings.emojiManager) {
0444             settings.emojiManager->replaceEmojis(&htmlChunk);
0445         }
0446         richTextStream << QLatin1String("<code style='background-color:") << codeBackgroundColor.name() << QLatin1String("'>") << htmlChunk
0447                        << QLatin1String("</code>");
0448     };
0449 
0450     auto addInlineQuoteCodeNewLineChunk = [&]() {
0451         richTextStream << QLatin1String("<br />");
0452     };
0453 
0454     auto addInlineQuoteChunk = [&](const QString &chunk) {
0455         iterateOverEndLineRegions(chunk, QStringLiteral(">"), addInlineQuoteCodeChunk, addTextChunk, addInlineQuoteCodeNewLineChunk);
0456     };
0457     auto addNonCodeChunk = [&](QString chunk) {
0458         chunk = chunk.trimmed();
0459         if (chunk.isEmpty()) {
0460             return;
0461         }
0462 
0463         richTextStream << QLatin1String("<div>");
0464         iterateOverRegions(chunk, QStringLiteral("`"), addInlineCodeChunk, addInlineQuoteChunk);
0465         richTextStream << QLatin1String("</div>");
0466     };
0467 
0468     iterateOverRegions(str, QStringLiteral("```"), addCodeChunk, addNonCodeChunk);
0469 
0470     return QLatin1String("<qt>") + quotedMessage + richText + QLatin1String("</qt>");
0471 }