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 ®ionMarker, 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 ®ionMarker, 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 ®ionMarker, 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 ®ionMarker, 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 }