/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "export/output/export_output_html.h" #include "core/utils.h" #include "countries/countries_instance.h" #include "export/data/export_data_types.h" #include "export/output/export_output_result.h" #include "ui/text/format_values.h" #include #include #include #include #include #include namespace Export { namespace Output { namespace { constexpr auto kMessagesInFile = 1000; constexpr auto kPersonalUserpicSize = 90; constexpr auto kEntryUserpicSize = 48; constexpr auto kServiceMessagePhotoSize = 60; constexpr auto kHistoryUserpicSize = 42; constexpr auto kSavedMessagesColorIndex = uint8(3); constexpr auto kJoinWithinSeconds = 900; constexpr auto kPhotoMaxWidth = 520; constexpr auto kPhotoMaxHeight = 520; constexpr auto kPhotoMinWidth = 80; constexpr auto kPhotoMinHeight = 80; constexpr auto kStickerMaxWidth = 384; constexpr auto kStickerMaxHeight = 384; constexpr auto kStickerMinWidth = 80; constexpr auto kStickerMinHeight = 80; constexpr auto kStoryThumbWidth = 45; constexpr auto kStoryThumbHeight = 80; constexpr auto kChatsPriority = 0; constexpr auto kContactsPriority = 2; constexpr auto kFrequentContactsPriority = 3; constexpr auto kUserpicsPriority = 4; constexpr auto kStoriesPriority = 5; constexpr auto kProfileMusicPriority = 6; constexpr auto kSessionsPriority = 7; constexpr auto kWebSessionsPriority = 8; constexpr auto kOtherPriority = 9; const auto kLineBreak = QByteArrayLiteral("
"); using Context = details::HtmlContext; using UserpicData = details::UserpicData; using StoryData = details::StoryData; using PeersMap = details::PeersMap; using MediaData = details::MediaData; bool IsGlobalLink(const QString &link) { return link.startsWith(u"http://"_q, Qt::CaseInsensitive) || link.startsWith(u"https://"_q, Qt::CaseInsensitive); } QByteArray NoFileDescription(Data::File::SkipReason reason) { using SkipReason = Data::File::SkipReason; switch (reason) { case SkipReason::Unavailable: return "Unavailable, please try again later."; case SkipReason::FileSize: return "Exceeds maximum size, " "change data exporting settings to download."; case SkipReason::FileType: return "Not included, " "change data exporting settings to download."; case SkipReason::None: return ""; } Unexpected("Skip reason in NoFileDescription."); } Data::File RichFilePresentation(const Data::File *file) { using SkipReason = Data::File::SkipReason; auto result = file ? *file : Data::File(); if (!result.relativePath.isEmpty()) { result.skipReason = SkipReason::None; } else { switch (result.skipReason) { case SkipReason::FileType: case SkipReason::FileSize: break; case SkipReason::None: case SkipReason::Unavailable: case SkipReason::DateLimits: result.skipReason = SkipReason::Unavailable; break; } } return result; } Data::Photo RichPhotoPresentation(const Data::Photo *photo) { auto result = photo ? *photo : Data::Photo(); result.image.file = RichFilePresentation( photo ? &photo->image.file : nullptr); return result; } Data::Document RichDocumentPresentation(const Data::Document *document) { auto result = document ? *document : Data::Document(); result.file = RichFilePresentation(document ? &document->file : nullptr); result.thumb.file = RichFilePresentation( document ? &document->thumb.file : nullptr); return result; } QByteArray RichFileDescription(const Data::File *file) { return NoFileDescription(RichFilePresentation(file).skipReason); } auto CalculateThumbSize( int maxWidth, int maxHeight, int minWidth, int minHeight, bool expandForRetina = false) { return [=](QSize largeSize) { const auto multiplier = (expandForRetina ? 2 : 1); const auto checkWidth = largeSize.width() * multiplier; const auto checkHeight = largeSize.height() * multiplier; const auto smallSize = (checkWidth > maxWidth || checkHeight > maxHeight) ? largeSize.scaled( maxWidth, maxHeight, Qt::KeepAspectRatio) : largeSize; const auto retinaSize = QSize( smallSize.width() & ~0x01, smallSize.height() & ~0x01); return (retinaSize.width() < kPhotoMinWidth || retinaSize.height() < kPhotoMinHeight) ? QSize() : retinaSize; }; } QByteArray SerializeString(const QByteArray &value) { const auto size = value.size(); const auto begin = value.data(); const auto end = begin + size; auto result = QByteArray(); result.reserve(size * 6); for (auto p = begin; p != end; ++p) { const auto ch = *p; if (ch == '\n') { result.append("
", 4); } else if (ch == '"') { result.append(""", 6); } else if (ch == '&') { result.append("&", 5); } else if (ch == '\'') { result.append("'", 6); } else if (ch == '<') { result.append("<", 4); } else if (ch == '>') { result.append(">", 4); } else if (ch >= 0 && ch < 32) { result.append("&#x", 3).append('0' + (ch >> 4)); const auto left = (ch & 0x0F); if (left >= 10) { result.append('A' + (left - 10)); } else { result.append('0' + left); } result.append(';'); } else if (ch == char(0xE2) && (p + 2 < end) && *(p + 1) == char(0x80)) { if (*(p + 2) == char(0xA8)) { // Line separator. result.append("
", 4); } else if (*(p + 2) == char(0xA9)) { // Paragraph separator. result.append("
", 4); } else { result.append(ch); } } else { result.append(ch); } } return result; } QByteArray SerializeList(const std::vector &values) { const auto count = values.size(); if (count == 1) { return values[0]; } else if (count > 1) { auto result = values[0]; for (auto i = 1; i != count - 1; ++i) { result += ", " + values[i]; } return result + " and " + values[count - 1]; } return QByteArray(); } QByteArray MakeLinks(const QByteArray &value) { const auto domain = QByteArray("https://telegram.org/"); auto result = QByteArray(); auto offset = 0; while (true) { const auto start = value.indexOf(domain, offset); if (start < 0) { break; } auto end = start + domain.size(); for (; end != value.size(); ++end) { const auto ch = value[end]; if ((ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') && (ch < '0' || ch > '9') && (ch != '-') && (ch != '_') && (ch != '/')) { break; } } if (start > offset) { const auto link = value.mid(start, end - start); result.append(value.mid(offset, start - offset)); result.append(""); result.append(link); result.append(""); offset = end; } } if (result.isEmpty()) { return value; } if (offset < value.size()) { result.append(value.mid(offset)); } return result; } QByteArray JoinList( const QByteArray &separator, const std::vector &list) { if (list.empty()) { return QByteArray(); } else if (list.size() == 1) { return list[0]; } auto size = (list.size() - 1) * separator.size(); for (const auto &value : list) { size += value.size(); } auto result = QByteArray(); result.reserve(size); auto counter = 0; while (true) { result.append(list[counter]); if (++counter == list.size()) { break; } else { result.append(separator); } } return result; } QByteArray FormatCustomEmoji( const Data::Utf8String &custom_emoji, const QByteArray &text, const QString &relativeLinkBase) { return (custom_emoji.isEmpty() ? "" : (custom_emoji == Data::TextPart::UnavailableEmoji()) ? "" : ("")) + text + ""; } QByteArray FormatText( const std::vector &data, const QString &internalLinksDomain, const QString &relativeLinkBase) { return JoinList(QByteArray(), ranges::views::all( data ) | ranges::views::transform([&](const Data::TextPart &part) { const auto text = SerializeString(part.text); using Type = Data::TextPart::Type; switch (part.type) { case Type::Text: return text; case Type::Unknown: return text; case Type::Mention: return "" + text + ""; case Type::Hashtag: return "" + text + ""; case Type::BotCommand: return "" + text + ""; case Type::Url: return "" + text + ""; case Type::Email: return "" + text + ""; case Type::Bold: return "" + text + ""; case Type::Italic: return "" + text + ""; case Type::Code: return "" + text + ""; case Type::Pre: return "
" + text + "
"; case Type::TextUrl: return "" + text + ""; case Type::MentionName: return "" + text + ""; case Type::Phone: return "" + text + ""; case Type::Cashtag: return "" + text + ""; case Type::Underline: return "" + text + ""; case Type::Strike: return "" + text + ""; case Type::Blockquote: return "
" + text + "
"; case Type::BankCard: return text; case Type::Spoiler: return "" "" + text + ""; case Type::CustomEmoji: return FormatCustomEmoji(part.additional, text, relativeLinkBase); } Unexpected("Type in text entities serialization."); }) | ranges::to_vector); } Data::Utf8String FormatUsername(const Data::Utf8String &username) { return username.isEmpty() ? username : ('@' + username); } bool DisplayDate(TimeId date, TimeId previousDate) { if (!previousDate) { return true; } return QDateTime::fromSecsSinceEpoch(date).date() != QDateTime::fromSecsSinceEpoch(previousDate).date(); } QByteArray FormatDateText(TimeId date) { const auto parsed = QDateTime::fromSecsSinceEpoch(date).date(); const auto month = [](int index) { switch (index) { case 1: return "January"; case 2: return "February"; case 3: return "March"; case 4: return "April"; case 5: return "May"; case 6: return "June"; case 7: return "July"; case 8: return "August"; case 9: return "September"; case 10: return "October"; case 11: return "November"; case 12: return "December"; } return "Unknown"; }; return Data::NumberToString(parsed.day()) + ' ' + month(parsed.month()) + ' ' + Data::NumberToString(parsed.year()); } QByteArray FormatTimeText(TimeId date) { const auto parsed = QDateTime::fromSecsSinceEpoch(date).time(); return Data::NumberToString(parsed.hour(), 2) + ':' + Data::NumberToString(parsed.minute(), 2); } } // namespace namespace details { struct UserpicData { uint8 colorIndex = 0; int pixelSize = 0; QString imageLink; QString largeLink; QByteArray firstName; QByteArray lastName; QByteArray tooltip; }; struct StoryData { QString imageLink; QString largeLink; }; class PeersMap { public: using Peer = Data::Peer; using User = Data::User; using Chat = Data::Chat; PeersMap(const std::map &data); const Peer &peer(PeerId peerId) const; const User &user(UserId userId) const; QByteArray wrapPeerName(PeerId peerId) const; QByteArray wrapUserName(UserId userId) const; QByteArray wrapUserNames(const std::vector &data) const; private: const std::map &_data; }; struct MediaData { QByteArray title; QByteArray description; QByteArray status; QByteArray classes; QString thumb; QString link; }; PeersMap::PeersMap(const std::map &data) : _data(data) { } auto PeersMap::peer(PeerId peerId) const -> const Peer & { if (const auto i = _data.find(peerId); i != end(_data)) { return i->second; } static auto empty = Peer{ User() }; return empty; } auto PeersMap::user(UserId userId) const -> const User & { if (const auto result = peer(peerFromUser(userId)).user()) { return *result; } static auto empty = User(); return empty; } QByteArray PeersMap::wrapPeerName(PeerId peerId) const { const auto result = peer(peerId).name(); return result.isEmpty() ? QByteArray("Deleted") : SerializeString(result); } QByteArray PeersMap::wrapUserName(UserId userId) const { const auto result = user(userId).name(); return result.isEmpty() ? QByteArray("Deleted Account") : SerializeString(result); } QByteArray PeersMap::wrapUserNames(const std::vector &data) const { auto list = std::vector(); for (const auto &userId : data) { list.push_back(wrapUserName(userId)); } return SerializeList(list); } QByteArray HtmlContext::pushTag( const QByteArray &tag, std::map &&attributes) { auto data = Tag(); data.name = tag; auto empty = false; auto inner = QByteArray(); for (const auto &[name, value] : attributes) { if (name == "inline") { data.block = false; } else if (name == "empty") { empty = true; } else { inner.append(' ').append(name); inner.append("=\"").append(SerializeString(value)).append("\""); } } auto result = (data.block ? ("\n" + indent()) : QByteArray()) + "<" + data.name + inner + (empty ? "/" : "") + ">" + (data.block ? "\n" : ""); if (!empty) { _tags.push_back(data); } return result; } QByteArray HtmlContext::popTag() { Expects(!_tags.empty()); const auto data = _tags.back(); _tags.pop_back(); return (data.block ? ("\n" + indent()) : QByteArray()) + "" + (data.block ? "\n" : ""); } QByteArray HtmlContext::indent() const { return QByteArray(_tags.size(), ' '); } bool HtmlContext::empty() const { return _tags.empty(); } } // namespace details namespace { MediaData PrepareAudioMediaData(const Data::Document &data) { const auto hasFile = !data.file.relativePath.isEmpty(); auto result = MediaData(); result.title = (!data.songPerformer.isEmpty() && !data.songTitle.isEmpty()) ? (data.songPerformer + " \xe2\x80\x93 " + data.songTitle) : !data.name.isEmpty() ? data.name : QByteArray("Audio file"); result.status = Data::FormatDuration(data.duration); if (!hasFile) { result.status += ", " + Data::FormatFileSize(data.file.size); } result.classes = "media_audio_file"; result.link = data.file.relativePath; result.description = NoFileDescription(data.file.skipReason); return result; } struct RichMediaCallbacks { Fn photo = {}; Fn video = {}; Fn audio = {}; Fn generic = {}; Fn photoCard = {}; }; enum class RichSourceMetaKind { Source, EmbedPost, }; enum class RichTailState { Empty, Media, Other, }; constexpr auto kRichTargetMaxBytes = 4096; constexpr auto kRichAnchorDirectBytes = 72; constexpr auto kRichAnchorPrefixBytes = 48; constexpr auto kRichTableSpanMax = 1000; constexpr auto kRichHashtagMinLength = 2; constexpr auto kRichHashtagMaxLength = 64; constexpr auto kRichCommandMaxLength = 64; constexpr auto kRichCashtagMaxLength = 8; constexpr auto kRichUsernameMinLength = 5; constexpr auto kRichUsernameMaxLength = 32; class RichHtmlRenderer { public: RichHtmlRenderer( Context &context, const Data::RichMessage &message, int messageId, const QString &internalLinksDomain, const QByteArray &relativeBase, const RichMediaCallbacks &media); [[nodiscard]] QByteArray renderMessage(); private: [[nodiscard]] const Data::Photo *findPhoto(uint64 id) const; [[nodiscard]] const Data::Document *findDocument(uint64 id) const; [[nodiscard]] RichTailState tailState( const Data::RichBlock &block) const; [[nodiscard]] RichTailState tailState( const std::vector &blocks) const; [[nodiscard]] QByteArray renderTexts( const std::vector &texts); [[nodiscard]] QByteArray renderText(const Data::RichText &text); [[nodiscard]] QByteArray renderTextChildren(const Data::RichText &text); [[nodiscard]] QByteArray renderTextLink( const Data::RichText &text, const std::optional &href, std::map attributes); [[nodiscard]] QByteArray renderCustomEmojiLink( const QByteArray &alt, std::map attributes); [[nodiscard]] QByteArray renderBlocks( const std::vector &blocks); [[nodiscard]] QByteArray renderBlock(const Data::RichBlock &block); [[nodiscard]] QByteArray renderPhoto(const Data::RichBlock &block); [[nodiscard]] QByteArray renderVideo(const Data::RichBlock &block); [[nodiscard]] QByteArray renderAudio(const Data::RichBlock &block); [[nodiscard]] QByteArray renderMediaGroup( const Data::RichBlock &block, const QByteArray &kind, bool renderGroupCaption); [[nodiscard]] QByteArray renderEmbed(const Data::RichBlock &block); [[nodiscard]] QByteArray renderEmbedPost(const Data::RichBlock &block); [[nodiscard]] QByteArray renderChannel(const Data::RichBlock &block); [[nodiscard]] QByteArray renderMap( const Data::RichBlock &block, const QByteArray &kind); [[nodiscard]] QByteArray renderRelatedArticles( const Data::RichBlock &block); [[nodiscard]] QByteArray renderRelatedArticle( const Data::RichRelatedArticle &article, int index); [[nodiscard]] QByteArray renderCaption( const Data::RichCaption &caption); [[nodiscard]] QByteArray renderSourceMeta( const QByteArray &source, RichSourceMetaKind kind); [[nodiscard]] QByteArray renderTextBlock( const QByteArray &tag, std::map attributes, const Data::RichText &text); [[nodiscard]] QByteArray renderAuthorDate( const Data::RichBlock &block); [[nodiscard]] QByteArray renderCode(const Data::RichBlock &block); [[nodiscard]] QByteArray renderList(const Data::RichBlock &block); [[nodiscard]] QByteArray renderListItem( const Data::RichListItem &item, bool ordered); [[nodiscard]] QByteArray renderQuote(const Data::RichBlock &block); [[nodiscard]] QByteArray renderDetails(const Data::RichBlock &block); [[nodiscard]] QByteArray renderTable(const Data::RichBlock &block); [[nodiscard]] QByteArray renderTableRow( const Data::RichTableRow &row); [[nodiscard]] QByteArray renderTableCell( const Data::RichTableCell &cell); [[nodiscard]] QByteArray renderFallback( const QByteArray &kind, const QByteArray &label); void collectAnchors(); void collectTextAnchors(const Data::RichText &text); void collectTextAnchors(const std::vector &texts); void collectCaptionAnchors(const Data::RichCaption &caption); void collectBlockAnchors(const Data::RichBlock &block); void collectBlockAnchors(const std::vector &blocks); [[nodiscard]] QByteArray registerAnchor(const QByteArray &name); [[nodiscard]] std::optional fragmentHref( const QByteArray &target) const; [[nodiscard]] std::optional textAnchorId( const Data::RichText &text) const; [[nodiscard]] std::optional blockAnchorId( const Data::RichBlock &block) const; Context &_context; const Data::RichMessage &_message; int _messageId = 0; bool _linkActive = false; const QString &_internalLinksDomain; const QByteArray &_relativeBase; const RichMediaCallbacks &_media; std::map _firstAnchorIds; std::map _anchorOccurrences; std::map _textAnchorIds; std::map _blockAnchorIds; }; std::map RichBlockAttributes( const QByteArray &classes, const QByteArray &kind) { return { { "class", QByteArray("rich_block ") + classes }, { "data-rich-kind", kind }, }; } QByteArray RichHeadingTag(int level) { switch (level) { case 1: return "h1"; case 2: return "h2"; case 3: return "h3"; case 4: return "h4"; case 5: return "h5"; case 6: return "h6"; } return "h6"; } QByteArray RichBoolAttribute(bool value) { return value ? QByteArray("true") : QByteArray("false"); } bool RichTextHasOutput(const Data::RichText &text) { using Type = Data::RichText::Type; switch (text.type) { case Type::Empty: return false; case Type::Plain: return !text.text.isEmpty(); case Type::Concat: for (const auto &child : text.children) { if (RichTextHasOutput(child)) { return true; } } return false; case Type::Bold: case Type::Italic: case Type::Underline: case Type::Strike: case Type::Fixed: case Type::Url: case Type::Email: case Type::Phone: case Type::Subscript: case Type::Superscript: case Type::Marked: case Type::Anchor: case Type::Math: case Type::CustomEmoji: case Type::Spoiler: case Type::Mention: case Type::Hashtag: case Type::BotCommand: case Type::Cashtag: case Type::AutoUrl: case Type::AutoEmail: case Type::AutoPhone: case Type::BankCard: case Type::MentionName: case Type::FormattedDate: case Type::InlineImage: case Type::Diff: return true; } Unexpected("Type in RichTextHasOutput."); } bool RichCaptionHasOutput(const Data::RichCaption &caption) { return RichTextHasOutput(caption.text) || RichTextHasOutput(caption.credit); } int HexValue(char value) { if (value >= '0' && value <= '9') { return value - '0'; } else if (value >= 'a' && value <= 'f') { return value - 'a' + 10; } else if (value >= 'A' && value <= 'F') { return value - 'A' + 10; } return -1; } bool HasEncodedControl(const QByteArray &value) { for (auto i = 0; i + 2 < value.size(); ++i) { if (value[i] != '%') { continue; } const auto high = HexValue(value[i + 1]); const auto low = HexValue(value[i + 2]); if (high < 0 || low < 0) { continue; } const auto decoded = (high << 4) | low; if (decoded < 32 || decoded == 127) { return true; } } return false; } bool IsStrictTarget(const QByteArray &value) { if (value.isEmpty() || value.size() > kRichTargetMaxBytes) { return false; } for (const auto character : value) { const auto byte = static_cast(character); if (byte < 32 || byte == 127) { return false; } } const auto decoded = QString::fromUtf8(value); if (decoded.toUtf8() != value || decoded.trimmed() != decoded) { return false; } for (const auto character : decoded) { const auto category = character.category(); if (category == QChar::Other_Control || category == QChar::Separator_Line || category == QChar::Separator_Paragraph) { return false; } } return true; } std::optional SafeHttpHref(const QByteArray &value) { if (!IsStrictTarget(value) || value.startsWith("//") || HasEncodedControl(value)) { return std::nullopt; } const auto url = QUrl::fromEncoded(value, QUrl::StrictMode); const auto scheme = url.scheme(); if (!url.isValid() || url.isRelative() || url.host().isEmpty() || (scheme.compare(u"http"_q, Qt::CaseInsensitive) != 0 && scheme.compare(u"https"_q, Qt::CaseInsensitive) != 0)) { return std::nullopt; } const auto result = url.toEncoded(QUrl::FullyEncoded); return (IsStrictTarget(result) && !HasEncodedControl(result)) ? std::make_optional(result) : std::nullopt; } std::optional SafeEmailHref(const QByteArray &address) { if (!IsStrictTarget(address)) { return std::nullopt; } const auto separator = address.indexOf('@'); if (separator <= 0 || separator != address.lastIndexOf('@') || separator + 1 == address.size()) { return std::nullopt; } for (const auto character : QString::fromUtf8(address)) { if (character.isSpace()) { return std::nullopt; } } for (const auto forbidden : QByteArray("/\\:%?#&=")) { if (address.contains(forbidden)) { return std::nullopt; } } const auto result = QByteArray("mailto:") + address; return IsStrictTarget(result) ? std::make_optional(result) : std::nullopt; } std::optional SafePhoneHref(const QByteArray &phone) { if (!IsStrictTarget(phone)) { return std::nullopt; } auto hasDigit = false; for (const auto character : phone) { if (character >= '0' && character <= '9') { hasDigit = true; } else if (character != '+' && character != '-' && character != ' ' && character != '.' && character != '(' && character != ')') { return std::nullopt; } } const auto result = QByteArray("tel:") + phone; return (hasDigit && IsStrictTarget(result)) ? std::make_optional(result) : std::nullopt; } std::optional SafeMentionHref( const QByteArray &mention, const QString &internalLinksDomain) { if (!IsStrictTarget(mention) || mention.size() < 2 || mention.front() != '@') { return std::nullopt; } const auto username = mention.mid(1); for (const auto character : username) { if ((character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_') { return std::nullopt; } } return SafeHttpHref(internalLinksDomain.toUtf8() + username); } bool IsAsciiWord( const QByteArray &value, int minimumLength, int maximumLength) { if (value.size() < minimumLength || value.size() > maximumLength) { return false; } for (const auto character : value) { if ((character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '_') { return false; } } return true; } QByteArray RichChannelSourceName(Data::RichChannel::Source source) { using Source = Data::RichChannel::Source; switch (source) { case Source::ChatEmpty: return "chat_empty"; case Source::Chat: return "chat"; case Source::ChatForbidden: return "chat_forbidden"; case Source::Channel: return "channel"; case Source::ChannelForbidden: return "channel_forbidden"; case Source::Community: return "community"; case Source::CommunityForbidden: return "community_forbidden"; } Unexpected("Source in RichChannelSourceName."); } QByteArray RichChannelStatus(const Data::RichChannel &channel) { using Source = Data::RichChannel::Source; auto label = QByteArray(); switch (channel.source) { case Source::ChatEmpty: case Source::Chat: label = "Chat"; break; case Source::ChatForbidden: label = "Chat unavailable"; break; case Source::Channel: label = "Channel"; break; case Source::ChannelForbidden: label = "Channel unavailable"; break; case Source::Community: label = "Community"; break; case Source::CommunityForbidden: label = "Community unavailable"; break; } if (label.isEmpty()) { Unexpected("Source in RichChannelStatus."); } return (channel.username && !channel.username->isEmpty()) ? ('@' + *channel.username + ", " + label) : label; } QByteArray RichMapPointSourceName(Data::RichMapPoint::Source source) { using Source = Data::RichMapPoint::Source; switch (source) { case Source::GeoPointEmpty: return "geo_point_empty"; case Source::GeoPoint: return "geo_point"; case Source::InputGeoPointEmpty: return "input_geo_point_empty"; case Source::InputGeoPoint: return "input_geo_point"; } Unexpected("Source in RichMapPointSourceName."); } struct RichMapCoordinateValues { QByteArray latitude; QByteArray longitude; }; std::optional RichMapCoordinates( const Data::RichMapPoint &point) { using Source = Data::RichMapPoint::Source; switch (point.source) { case Source::GeoPointEmpty: case Source::InputGeoPointEmpty: return std::nullopt; case Source::GeoPoint: case Source::InputGeoPoint: break; } if (!std::isfinite(point.latitude) || !std::isfinite(point.longitude) || point.latitude < -90. || point.latitude > 90. || point.longitude < -180. || point.longitude > 180.) { return std::nullopt; } return RichMapCoordinateValues{ Data::NumberToString(point.latitude), Data::NumberToString(point.longitude), }; } bool IsUnicodeHashtagWord(const QByteArray &value) { const auto characters = QString::fromUtf8(value).toUcs4(); if (characters.size() < kRichHashtagMinLength || characters.size() > kRichHashtagMaxLength) { return false; } auto allDecimalDigits = true; for (const auto character : characters) { const auto category = QChar::category(character); const auto isMark = (category >= QChar::Mark_NonSpacing) && (category <= QChar::Mark_Enclosing); if (!QChar::isLetterOrNumber(character) && category != QChar::Punctuation_Connector && !isMark) { return false; } allDecimalDigits = allDecimalDigits && QChar::isDigit(character); } return !allDecimalDigits; } bool IsHashtagAction(const QByteArray &value) { const auto separator = value.indexOf('@'); const auto hashtag = (separator < 0) ? value : value.left(separator); if (!IsUnicodeHashtagWord(hashtag)) { return false; } return (separator < 0) || (separator == value.lastIndexOf('@') && IsAsciiWord( value.mid(separator + 1), 1, kRichUsernameMaxLength)); } bool IsBotCommandAction(const QByteArray &value) { const auto separator = value.indexOf('@'); const auto command = (separator < 0) ? value : value.left(separator); if (!IsAsciiWord(command, 1, kRichCommandMaxLength)) { return false; } return (separator < 0) || (separator == value.lastIndexOf('@') && IsAsciiWord( value.mid(separator + 1), kRichUsernameMinLength, kRichUsernameMaxLength)); } bool IsCashtagAction(const QByteArray &value) { if (value.isEmpty() || value.size() > kRichCashtagMaxLength) { return false; } for (const auto character : value) { if (character < 'A' || character > 'Z') { return false; } } return true; } bool IsValidActionValue(const QByteArray &value, char prefix) { switch (prefix) { case '#': return IsHashtagAction(value); case '/': return IsBotCommandAction(value); case '$': return IsCashtagAction(value); } return false; } std::optional SafeActionData( const QByteArray &target, char prefix) { if (!IsStrictTarget(target) || target.front() != prefix || target.size() < 2) { return std::nullopt; } const auto result = target.mid(1); return (IsValidActionValue(result, prefix) && IsStrictTarget(result)) ? std::make_optional(result) : std::nullopt; } bool IsAsciiDecimal(const QByteArray &value) { if (value.isEmpty()) { return false; } for (const auto character : value) { if (character < '0' || character > '9') { return false; } } return true; } std::optional SafeRelativeEmojiHref( const QByteArray &path, const QByteArray &relativeBase) { if (!IsStrictTarget(path) || path.startsWith('/') || path.contains('\\') || path.contains(':') || path.contains('%') || path.contains('?') || path.contains('#') || path == Data::TextPart::UnavailableEmoji() || IsAsciiDecimal(path)) { return std::nullopt; } for (const auto &component : path.split('/')) { if (component.isEmpty() || component == "." || component == "..") { return std::nullopt; } } const auto result = relativeBase + path; return IsStrictTarget(result) ? std::make_optional(result) : std::nullopt; } bool AppendPlainTarget( QByteArray &result, const QByteArray &value) { if (result.size() > kRichTargetMaxBytes || value.size() > kRichTargetMaxBytes - result.size()) { return false; } result.append(value); return true; } bool AppendPlainTarget( QByteArray &result, const std::vector &texts); bool AppendPlainTarget( QByteArray &result, const Data::RichText &text) { using Type = Data::RichText::Type; switch (text.type) { case Type::Empty: return true; case Type::Plain: return AppendPlainTarget(result, text.text); case Type::Concat: case Type::Bold: case Type::Italic: case Type::Underline: case Type::Strike: case Type::Fixed: case Type::Url: case Type::Email: case Type::Phone: case Type::Subscript: case Type::Superscript: case Type::Marked: case Type::Anchor: case Type::Spoiler: case Type::Mention: case Type::Hashtag: case Type::BotCommand: case Type::Cashtag: case Type::AutoUrl: case Type::AutoEmail: case Type::AutoPhone: case Type::BankCard: case Type::MentionName: case Type::FormattedDate: return AppendPlainTarget(result, text.children); case Type::CustomEmoji: return AppendPlainTarget(result, text.text); case Type::Diff: return AppendPlainTarget(result, text.children); case Type::Math: case Type::InlineImage: return false; } return false; } bool AppendPlainTarget( QByteArray &result, const std::vector &texts) { for (const auto &text : texts) { if (!AppendPlainTarget(result, text)) { return false; } } return true; } std::optional PlainTarget(const Data::RichText &text) { auto result = QByteArray(); return AppendPlainTarget(result, text) ? std::make_optional(result) : std::nullopt; } QByteArray AnchorToken(const QByteArray &name) { if (name.isEmpty()) { return "empty"; } const auto encoded = [](const QByteArray &value) { return value.toBase64( QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); }; if (name.size() <= kRichAnchorDirectBytes) { return encoded(name); } const auto hash = hashSha256(name.data(), int(name.size())); return encoded(name.left(kRichAnchorPrefixBytes)) + '-' + QByteArray(hash.data(), int(hash.size())).toHex().left(16); } RichHtmlRenderer::RichHtmlRenderer( Context &context, const Data::RichMessage &message, int messageId, const QString &internalLinksDomain, const QByteArray &relativeBase, const RichMediaCallbacks &media) : _context(context) , _message(message) , _messageId(messageId) , _internalLinksDomain(internalLinksDomain) , _relativeBase(relativeBase) , _media(media) { } const Data::Photo *RichHtmlRenderer::findPhoto(uint64 id) const { if (!id) { return nullptr; } const auto i = _message.photos.find(id); return (i != end(_message.photos)) ? &i->second : nullptr; } const Data::Document *RichHtmlRenderer::findDocument(uint64 id) const { if (!id) { return nullptr; } const auto i = _message.documents.find(id); return (i != end(_message.documents)) ? &i->second : nullptr; } RichTailState RichHtmlRenderer::tailState( const std::vector &blocks) const { for (auto i = blocks.rbegin(); i != blocks.rend(); ++i) { const auto state = tailState(*i); if (state != RichTailState::Empty) { return state; } } return RichTailState::Empty; } RichTailState RichHtmlRenderer::tailState( const Data::RichBlock &block) const { using Content = Data::RichListItemContent; using Kind = Data::RichBlock::Kind; using QuoteContent = Data::RichQuoteContent; switch (block.kind) { case Kind::Unsupported: case Kind::Heading: case Kind::Paragraph: case Kind::Footer: case Kind::Thinking: case Kind::AuthorDate: case Kind::Code: case Kind::Divider: case Kind::Math: case Kind::Table: case Kind::Unknown: return RichTailState::Other; case Kind::Anchor: return RichTailState::Empty; case Kind::List: { if (block.listItems.empty()) { return RichTailState::Empty; } const auto &item = block.listItems.back(); if (item.content == Content::Blocks) { const auto state = tailState(item.blocks); if (state != RichTailState::Empty) { return state; } } return RichTailState::Other; } case Kind::Quote: if (RichTextHasOutput(block.quoteCaption)) { return RichTailState::Other; } if (block.quoteContent == QuoteContent::Blocks) { const auto state = tailState(block.blocks); if (state != RichTailState::Empty) { return state; } } return RichTailState::Other; case Kind::Photo: return (RichCaptionHasOutput(block.caption) || (block.optionalUrl && !block.optionalUrl->isEmpty())) ? RichTailState::Other : RichTailState::Media; case Kind::Video: case Kind::Audio: case Kind::Map: case Kind::InputMap: return RichCaptionHasOutput(block.caption) ? RichTailState::Other : RichTailState::Media; case Kind::Cover: return tailState(block.blocks); case Kind::Embed: return (RichCaptionHasOutput(block.caption) || (block.posterPhotoId && block.optionalUrl && !block.optionalUrl->isEmpty())) ? RichTailState::Other : RichTailState::Media; case Kind::EmbedPost: { if (RichCaptionHasOutput(block.caption)) { return RichTailState::Other; } const auto state = tailState(block.blocks); if (state != RichTailState::Empty) { return state; } return block.url.isEmpty() ? RichTailState::Media : RichTailState::Other; } case Kind::Collage: case Kind::Slideshow: return RichCaptionHasOutput(block.caption) ? RichTailState::Other : tailState(block.blocks); case Kind::Channel: return RichTailState::Media; case Kind::Details: if (block.open) { const auto state = tailState(block.blocks); if (state != RichTailState::Empty) { return state; } } return RichTailState::Other; case Kind::RelatedArticles: if (block.relatedArticles.empty()) { return RichTextHasOutput(block.text) ? RichTailState::Other : RichTailState::Empty; } else { const auto &article = block.relatedArticles.back(); if (!article.url.isEmpty()) { return RichTailState::Other; } const auto description = article.description ? *article.description : QByteArray(); if (article.photoId && !description.isEmpty()) { const auto photo = findPhoto(*article.photoId); if (!RichFileDescription( photo ? &photo->image.file : nullptr).isEmpty()) { return RichTailState::Other; } } return RichTailState::Media; } } Unexpected("Kind in RichHtmlRenderer::tailState."); } QByteArray RichHtmlRenderer::renderMessage() { collectAnchors(); auto result = _context.pushTag( "div", { { "class", "text rich_message" }, { "data-rich-message", Data::NumberToString(_messageId) }, { "data-rich-part", RichBoolAttribute(_message.part) }, { "dir", _message.rtl ? QByteArray("rtl") : QByteArray("auto") }, { "inline", QByteArray() }, }); result += renderBlocks(_message.blocks); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderCaption( const Data::RichCaption &caption) { const auto text = renderText(caption.text); const auto credit = renderText(caption.credit); if (text.isEmpty() && credit.isEmpty()) { return QByteArray(); } auto result = _context.pushTag("figcaption", { { "class", "rich_media_caption" }, { "dir", "auto" }, }); if (!text.isEmpty()) { result += _context.pushTag("div", { { "class", "rich_caption_text" }, }); result += text; result += _context.popTag(); } if (!credit.isEmpty()) { result += _context.pushTag("cite", { { "class", "rich_caption_credit" }, }); result += credit; result += _context.popTag(); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderSourceMeta( const QByteArray &source, RichSourceMetaKind kind) { if (source.isEmpty()) { return QByteArray(); } const auto containerClass = [kind] { switch (kind) { case RichSourceMetaKind::Source: return QByteArray("rich_source_meta"); case RichSourceMetaKind::EmbedPost: return QByteArray("rich_embed_post_meta"); } Unexpected("Kind in RichHtmlRenderer::renderSourceMeta."); }(); auto result = _context.pushTag("div", { { "class", containerClass }, }); const auto href = SafeHttpHref(source); if (href && !_linkActive) { result += _context.pushTag("a", { { "class", "rich_source_link" }, { "href", *href }, { "inline", QByteArray() }, }); const auto previous = _linkActive; _linkActive = true; const auto guard = gsl::finally([&] { _linkActive = previous; }); result += SerializeString(source); result += _context.popTag(); } else { result += _context.pushTag("span", { { "class", "rich_source_text" }, { "inline", QByteArray() }, }); result += SerializeString(source); result += _context.popTag(); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderPhoto( const Data::RichBlock &block) { auto attributes = RichBlockAttributes("rich_media_item", "photo"); attributes.emplace( "data-photo-id", Data::NumberToString(block.photoId)); attributes.emplace("data-spoiler", RichBoolAttribute(block.spoiler)); if (block.optionalWebpageId) { attributes.emplace( "data-webpage-id", Data::NumberToString(*block.optionalWebpageId)); } auto result = _context.pushTag("figure", std::move(attributes)); result += _media.photo(findPhoto(block.photoId)); if (block.optionalUrl) { result += renderSourceMeta( *block.optionalUrl, RichSourceMetaKind::Source); } result += renderCaption(block.caption); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderVideo( const Data::RichBlock &block) { auto attributes = RichBlockAttributes("rich_media_item", "video"); attributes.emplace( "data-document-id", Data::NumberToString(block.documentId)); attributes.emplace("data-autoplay", RichBoolAttribute(block.autoplay)); attributes.emplace("data-loop", RichBoolAttribute(block.loop)); attributes.emplace("data-spoiler", RichBoolAttribute(block.spoiler)); auto result = _context.pushTag("figure", std::move(attributes)); result += _media.video(findDocument(block.documentId)); result += renderCaption(block.caption); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderAudio( const Data::RichBlock &block) { auto attributes = RichBlockAttributes("rich_media_item", "audio"); attributes.emplace( "data-document-id", Data::NumberToString(block.documentId)); auto result = _context.pushTag("figure", std::move(attributes)); result += _media.audio(findDocument(block.documentId)); result += renderCaption(block.caption); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderMediaGroup( const Data::RichBlock &block, const QByteArray &kind, bool renderGroupCaption) { auto attributes = RichBlockAttributes("rich_media_group", kind); attributes.emplace( "data-rich-has-items", RichBoolAttribute(!block.blocks.empty())); attributes.emplace( "data-rich-items-end-media", RichBoolAttribute( tailState(block.blocks) == RichTailState::Media)); auto result = _context.pushTag("section", std::move(attributes)); result += _context.pushTag("div", { { "class", "rich_media_group_items" }, }); result += renderBlocks(block.blocks); result += _context.popTag(); if (renderGroupCaption) { result += renderCaption(block.caption); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderEmbed( const Data::RichBlock &block) { const auto hasPoster = block.posterPhotoId.has_value(); const auto hasMeta = hasPoster && block.optionalUrl && !block.optionalUrl->isEmpty(); auto attributes = RichBlockAttributes( "rich_media_item rich_embed", "embed"); attributes.emplace( "data-full-width", RichBoolAttribute(block.fullWidth)); attributes.emplace( "data-allow-scrolling", RichBoolAttribute(block.allowScrolling)); attributes.emplace("data-rich-has-meta", RichBoolAttribute(hasMeta)); if (block.posterPhotoId) { attributes.emplace( "data-poster-photo-id", Data::NumberToString(*block.posterPhotoId)); } if (block.width) { attributes.emplace( "data-source-width", Data::NumberToString(*block.width)); } if (block.height) { attributes.emplace( "data-source-height", Data::NumberToString(*block.height)); } auto result = _context.pushTag("figure", std::move(attributes)); if (hasPoster) { result += _media.photo(findPhoto(*block.posterPhotoId)); if (block.optionalUrl) { result += renderSourceMeta( *block.optionalUrl, RichSourceMetaKind::Source); } } else { auto media = MediaData(); media.title = "Embed"; media.classes = "media_file"; if (block.optionalUrl) { media.status = *block.optionalUrl; if (const auto href = SafeHttpHref(*block.optionalUrl)) { media.link = QString::fromUtf8(*href); } } result += _media.generic(media); } result += renderCaption(block.caption); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderEmbedPost( const Data::RichBlock &block) { const auto photo = findPhoto(block.authorPhotoId); const auto hasMeta = !block.url.isEmpty(); auto attributes = RichBlockAttributes("rich_embed_post", "embed-post"); attributes.emplace( "data-webpage-id", Data::NumberToString(block.webpageId)); attributes.emplace( "data-author-photo-id", Data::NumberToString(block.authorPhotoId)); attributes.emplace("data-date", Data::NumberToString(block.date)); attributes.emplace("data-rich-has-meta", RichBoolAttribute(hasMeta)); attributes.emplace( "data-rich-has-items", RichBoolAttribute(!block.blocks.empty())); attributes.emplace( "data-rich-items-end-media", RichBoolAttribute( tailState(block.blocks) == RichTailState::Media)); auto result = _context.pushTag("section", std::move(attributes)); result += _context.pushTag("div", { { "class", "rich_embed_post_author" }, }); auto media = MediaData(); media.title = block.author.isEmpty() ? QByteArray("Embed post") : block.author; media.description = RichFileDescription( photo ? &photo->image.file : nullptr); media.status = block.date ? Data::FormatDateTime(block.date) : QByteArray(); media.classes = "media_contact"; result += _media.photoCard(media, photo); result += _context.popTag(); result += renderSourceMeta(block.url, RichSourceMetaKind::EmbedPost); result += _context.pushTag("div", { { "class", "rich_embed_post_blocks" }, }); result += renderBlocks(block.blocks); result += _context.popTag(); result += renderCaption(block.caption); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderChannel( const Data::RichBlock &block) { const auto &channel = block.channel; auto attributes = RichBlockAttributes( "rich_reference_block", "channel"); attributes.emplace( "data-channel-source", RichChannelSourceName(channel.source)); attributes.emplace("data-channel-id", Data::NumberToString(channel.id)); attributes.emplace("data-broadcast", RichBoolAttribute(channel.broadcast)); attributes.emplace("data-megagroup", RichBoolAttribute(channel.megagroup)); attributes.emplace("data-monoforum", RichBoolAttribute(channel.monoforum)); if (channel.accessHash) { attributes.emplace( "data-access-hash", Data::NumberToString(*channel.accessHash)); } auto result = _context.pushTag("section", std::move(attributes)); auto media = MediaData(); media.title = (channel.title && !channel.title->isEmpty()) ? *channel.title : QByteArray("Channel"); media.status = RichChannelStatus(channel); media.classes = "media_contact"; if (channel.username && IsAsciiWord( *channel.username, kRichUsernameMinLength, kRichUsernameMaxLength)) { const auto target = _internalLinksDomain.toUtf8() + *channel.username; if (const auto href = SafeHttpHref(target)) { media.link = QString::fromUtf8(*href); } } result += _media.generic(media); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderMap( const Data::RichBlock &block, const QByteArray &kind) { const auto &point = block.mapPoint; auto attributes = RichBlockAttributes("rich_reference_block", kind); attributes.emplace( "data-point-source", RichMapPointSourceName(point.source)); attributes.emplace("data-zoom", Data::NumberToString(block.zoom)); attributes.emplace( "data-source-width", Data::NumberToString(block.mapWidth)); attributes.emplace( "data-source-height", Data::NumberToString(block.mapHeight)); if (point.accessHash) { attributes.emplace( "data-access-hash", Data::NumberToString(*point.accessHash)); } if (point.accuracyRadius) { attributes.emplace( "data-accuracy-radius", Data::NumberToString(*point.accuracyRadius)); } auto result = _context.pushTag("figure", std::move(attributes)); auto media = MediaData(); media.title = "Location"; media.classes = "media_location"; if (const auto coordinates = RichMapCoordinates(point)) { const auto joined = coordinates->latitude + ',' + coordinates->longitude; media.status = coordinates->latitude + ", " + coordinates->longitude; const auto target = QByteArray("https://maps.google.com/maps?q=") + joined + "&ll=" + joined + "&z=16"; if (const auto href = SafeHttpHref(target)) { media.link = QString::fromUtf8(*href); } } result += _media.generic(media); result += renderCaption(block.caption); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderRelatedArticle( const Data::RichRelatedArticle &article, int index) { auto attributes = std::map{ { "class", "rich_related_article" }, { "data-rich-article-index", Data::NumberToString(index) }, { "data-webpage-id", Data::NumberToString(article.webpageId) }, }; if (article.photoId) { attributes.emplace( "data-photo-id", Data::NumberToString(*article.photoId)); } if (article.publishedDate) { attributes.emplace( "data-published-date", Data::NumberToString(*article.publishedDate)); } auto result = _context.pushTag("article", std::move(attributes)); auto media = MediaData(); media.title = (article.title && !article.title->isEmpty()) ? *article.title : QByteArray("Related article"); if (article.author && !article.author->isEmpty()) { media.status = *article.author; } const auto publishedDate = article.publishedDate ? Data::FormatDateTime(*article.publishedDate) : QByteArray(); if (!publishedDate.isEmpty()) { if (!media.status.isEmpty()) { media.status += ", "; } media.status += publishedDate; } const auto description = article.description ? *article.description : QByteArray(); auto displacedDescription = false; if (article.photoId) { const auto photo = findPhoto(*article.photoId); const auto fileDescription = RichFileDescription( photo ? &photo->image.file : nullptr); media.classes = "media_photo"; media.description = fileDescription.isEmpty() ? description : fileDescription; displacedDescription = !fileDescription.isEmpty() && !description.isEmpty(); result += _media.photoCard(media, photo); } else { media.classes = "media_file"; media.description = description; result += _media.generic(media); } if (displacedDescription) { result += _context.pushTag("div", { { "class", "rich_article_description" }, }); result += SerializeString(description); result += _context.popTag(); } result += renderSourceMeta(article.url, RichSourceMetaKind::Source); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderRelatedArticles( const Data::RichBlock &block) { const auto title = renderText(block.text); auto result = _context.pushTag( "section", RichBlockAttributes( "rich_related_articles", "related-articles")); if (!title.isEmpty()) { result += _context.pushTag("div", { { "class", "rich_related_title" }, { "dir", "auto" }, }); result += title; result += _context.popTag(); } result += _context.pushTag("div", { { "class", "rich_related_items" }, }); auto index = 0; for (const auto &article : block.relatedArticles) { result += renderRelatedArticle(article, index++); } result += _context.popTag(); result += _context.popTag(); return result; } void RichHtmlRenderer::collectAnchors() { _firstAnchorIds.clear(); _anchorOccurrences.clear(); _textAnchorIds.clear(); _blockAnchorIds.clear(); collectBlockAnchors(_message.blocks); } QByteArray RichHtmlRenderer::registerAnchor(const QByteArray &name) { const auto base = QByteArray("rich-message-") + Data::NumberToString(_messageId) + "-anchor-" + AnchorToken(name); auto &occurrence = _anchorOccurrences[base]; ++occurrence; const auto result = base + ((occurrence > 1) ? ('-' + Data::NumberToString(occurrence)) : QByteArray()); _firstAnchorIds.emplace(name, result); return result; } void RichHtmlRenderer::collectTextAnchors(const Data::RichText &text) { using Type = Data::RichText::Type; switch (text.type) { case Type::Anchor: _textAnchorIds.emplace(&text, registerAnchor(text.data)); collectTextAnchors(text.children); break; case Type::Concat: case Type::Bold: case Type::Italic: case Type::Underline: case Type::Strike: case Type::Fixed: case Type::Url: case Type::Email: case Type::Phone: case Type::Subscript: case Type::Superscript: case Type::Marked: case Type::Spoiler: case Type::Mention: case Type::Hashtag: case Type::BotCommand: case Type::Cashtag: case Type::AutoUrl: case Type::AutoEmail: case Type::AutoPhone: case Type::BankCard: case Type::MentionName: case Type::FormattedDate: collectTextAnchors(text.children); break; case Type::Diff: collectTextAnchors(text.children); collectTextAnchors(text.oldChildren); break; case Type::Empty: case Type::Plain: case Type::Math: case Type::CustomEmoji: case Type::InlineImage: break; } } void RichHtmlRenderer::collectTextAnchors( const std::vector &texts) { for (const auto &text : texts) { collectTextAnchors(text); } } void RichHtmlRenderer::collectCaptionAnchors( const Data::RichCaption &caption) { collectTextAnchors(caption.text); collectTextAnchors(caption.credit); } void RichHtmlRenderer::collectBlockAnchors( const std::vector &blocks) { for (const auto &block : blocks) { collectBlockAnchors(block); } } void RichHtmlRenderer::collectBlockAnchors(const Data::RichBlock &block) { using Content = Data::RichListItemContent; using Kind = Data::RichBlock::Kind; using QuoteContent = Data::RichQuoteContent; switch (block.kind) { case Kind::Heading: case Kind::Paragraph: case Kind::Footer: case Kind::Thinking: case Kind::AuthorDate: case Kind::Code: collectTextAnchors(block.text); break; case Kind::Anchor: _blockAnchorIds.emplace(&block, registerAnchor(block.name)); break; case Kind::List: for (const auto &item : block.listItems) { switch (item.content) { case Content::Text: if (item.text) { collectTextAnchors(*item.text); } break; case Content::Blocks: collectBlockAnchors(item.blocks); break; } } break; case Kind::Quote: switch (block.quoteContent) { case QuoteContent::Text: collectTextAnchors(block.text); break; case QuoteContent::Blocks: collectBlockAnchors(block.blocks); break; } collectTextAnchors(block.quoteCaption); break; case Kind::Photo: case Kind::Video: case Kind::Audio: case Kind::Embed: case Kind::Map: case Kind::InputMap: collectCaptionAnchors(block.caption); break; case Kind::EmbedPost: collectBlockAnchors(block.blocks); collectCaptionAnchors(block.caption); break; case Kind::Cover: collectBlockAnchors(block.blocks); break; case Kind::Collage: case Kind::Slideshow: collectBlockAnchors(block.blocks); collectCaptionAnchors(block.caption); break; case Kind::Table: collectTextAnchors(block.text); for (const auto &row : block.tableRows) { for (const auto &cell : row.cells) { if (cell.text) { collectTextAnchors(*cell.text); } } } break; case Kind::Details: collectTextAnchors(block.text); collectBlockAnchors(block.blocks); break; case Kind::RelatedArticles: collectTextAnchors(block.text); break; case Kind::Unsupported: case Kind::Divider: case Kind::Channel: case Kind::Math: case Kind::Unknown: break; } } std::optional RichHtmlRenderer::fragmentHref( const QByteArray &target) const { if (!IsStrictTarget(target) || target.front() != '#') { return std::nullopt; } const auto i = _firstAnchorIds.find(target.mid(1)); return (i != end(_firstAnchorIds)) ? std::make_optional('#' + i->second) : std::nullopt; } std::optional RichHtmlRenderer::textAnchorId( const Data::RichText &text) const { const auto i = _textAnchorIds.find(&text); return (i != end(_textAnchorIds)) ? std::make_optional(i->second) : std::nullopt; } std::optional RichHtmlRenderer::blockAnchorId( const Data::RichBlock &block) const { const auto i = _blockAnchorIds.find(&block); return (i != end(_blockAnchorIds)) ? std::make_optional(i->second) : std::nullopt; } QByteArray RichHtmlRenderer::renderTexts( const std::vector &texts) { auto result = QByteArray(); for (const auto &text : texts) { result += renderText(text); } return result; } QByteArray RichHtmlRenderer::renderTextChildren( const Data::RichText &text) { if (!text.children.empty()) { return renderTexts(text.children); } auto result = _context.pushTag("span", { { "class", "rich_inline_fallback" }, { "data-rich-kind", "unsupported-text" }, { "inline", QByteArray() }, }); result += "Unsupported rich text"; result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderTextLink( const Data::RichText &text, const std::optional &href, std::map attributes) { attributes.emplace("inline", QByteArray()); if (!href || _linkActive) { attributes.erase("href"); attributes.erase("onclick"); attributes.emplace("class", "rich_inert_link"); auto result = _context.pushTag("span", std::move(attributes)); result += renderTextChildren(text); result += _context.popTag(); return result; } attributes.emplace("href", *href); auto result = _context.pushTag("a", std::move(attributes)); const auto previous = _linkActive; _linkActive = true; const auto guard = gsl::finally([&] { _linkActive = previous; }); result += renderTextChildren(text); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderCustomEmojiLink( const QByteArray &alt, std::map attributes) { attributes.emplace("inline", QByteArray()); if (_linkActive) { attributes.erase("href"); attributes.erase("onclick"); attributes.emplace("class", "rich_inert_link"); auto result = _context.pushTag("span", std::move(attributes)); result += alt; result += _context.popTag(); return result; } auto result = _context.pushTag("a", std::move(attributes)); const auto previous = _linkActive; _linkActive = true; const auto guard = gsl::finally([&] { _linkActive = previous; }); result += alt; result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderText(const Data::RichText &text) { using Attributes = std::map; using Type = Data::RichText::Type; const auto wrapChildren = [this, &text]( const QByteArray &tag, Attributes attributes) { attributes.emplace("inline", QByteArray()); auto result = _context.pushTag(tag, std::move(attributes)); result += renderTextChildren(text); result += _context.popTag(); return result; }; switch (text.type) { case Type::Empty: return QByteArray(); case Type::Plain: return SerializeString(text.text); case Type::Concat: return renderTexts(text.children); case Type::Bold: return wrapChildren("strong", {}); case Type::Italic: return wrapChildren("em", {}); case Type::Underline: return wrapChildren("u", {}); case Type::Strike: return wrapChildren("s", {}); case Type::Fixed: return wrapChildren("code", { { "class", "rich_inline_code" }, }); case Type::Url: { const auto href = text.data.startsWith('#') ? fragmentHref(text.data) : SafeHttpHref(text.data); auto attributes = Attributes(); if (text.id) { attributes.emplace( "data-webpage-id", Data::NumberToString(text.id)); } return renderTextLink(text, href, std::move(attributes)); } case Type::Email: return renderTextLink(text, SafeEmailHref(text.data), {}); case Type::Phone: return renderTextLink(text, SafePhoneHref(text.data), {}); case Type::Subscript: return wrapChildren("sub", {}); case Type::Superscript: return wrapChildren("sup", {}); case Type::Marked: return wrapChildren("mark", {}); case Type::Anchor: { auto attributes = Attributes{ { "class", "rich_reference" }, { "inline", QByteArray() }, }; if (const auto id = textAnchorId(text)) { attributes.emplace("id", *id); } auto result = _context.pushTag("span", std::move(attributes)); result += renderTexts(text.children); result += _context.popTag(); return result; } case Type::Math: { auto result = _context.pushTag("span", { { "class", "rich_math_inline" }, { "data-rich-kind", "inline-math" }, { "dir", "ltr" }, { "inline", QByteArray() }, }); result += SerializeString(text.data); result += _context.popTag(); return result; } case Type::CustomEmoji: { auto result = _context.pushTag("span", { { "class", "rich_custom_emoji" }, { "data-document-id", Data::NumberToString(text.id) }, { "inline", QByteArray() }, }); const auto alt = SerializeString(text.text); const auto unavailable = (text.customEmojiData == Data::TextPart::UnavailableEmoji()); const auto notLoaded = text.customEmojiData.isEmpty() || IsAsciiDecimal(text.customEmojiData); const auto href = (unavailable || notLoaded) ? std::optional() : SafeRelativeEmojiHref(text.customEmojiData, _relativeBase); if (href) { result += renderCustomEmojiLink(alt, { { "href", *href }, }); } else if (unavailable || notLoaded) { result += renderCustomEmojiLink(alt, { { "href", QByteArray() }, { "onclick", unavailable ? QByteArray("return ShowNotAvailableEmoji()") : QByteArray("return ShowNotLoadedEmoji()") }, }); } else { result += alt; } result += _context.popTag(); return result; } case Type::Spoiler: { auto result = _context.pushTag("span", { { "class", "spoiler hidden" }, { "inline", QByteArray() }, { "onclick", "ShowSpoiler(this)" }, }); result += _context.pushTag("span", { { "aria-hidden", "true" }, { "inline", QByteArray() }, }); result += renderTextChildren(text); result += _context.popTag(); result += _context.popTag(); return result; } case Type::Mention: { const auto target = PlainTarget(text); const auto href = target ? SafeMentionHref(*target, _internalLinksDomain) : std::optional(); return renderTextLink(text, href, {}); } case Type::Hashtag: case Type::BotCommand: case Type::Cashtag: { const auto target = PlainTarget(text); const auto prefix = (text.type == Type::Hashtag) ? '#' : (text.type == Type::BotCommand) ? '/' : '$'; const auto action = target ? SafeActionData(*target, prefix) : std::optional(); if (!action) { return renderTextLink(text, std::nullopt, {}); } const auto attribute = (text.type == Type::BotCommand) ? QByteArray("data-command") : QByteArray("data-tag"); const auto callback = (text.type == Type::Hashtag) ? QByteArray("return ShowHashtag(this.dataset.tag)") : (text.type == Type::BotCommand) ? QByteArray("return ShowBotCommand(this.dataset.command)") : QByteArray("return ShowCashtag(this.dataset.tag)"); return renderTextLink(text, QByteArray(), { { attribute, *action }, { "onclick", callback }, }); } case Type::AutoUrl: { const auto target = PlainTarget(text); const auto href = target ? SafeHttpHref(*target) : std::optional(); return renderTextLink(text, href, {}); } case Type::AutoEmail: { const auto target = PlainTarget(text); const auto href = target ? SafeEmailHref(*target) : std::optional(); return renderTextLink(text, href, {}); } case Type::AutoPhone: { const auto target = PlainTarget(text); const auto href = target ? SafePhoneHref(*target) : std::optional(); return renderTextLink(text, href, {}); } case Type::BankCard: return wrapChildren("span", { { "class", "rich_bank_card" }, }); case Type::MentionName: return renderTextLink(text, QByteArray(), { { "data-user-id", Data::NumberToString(text.id) }, { "onclick", "return ShowMentionName()" }, }); case Type::FormattedDate: { const auto datetime = QDateTime::fromSecsSinceEpoch(text.date); auto result = _context.pushTag("time", { { "class", "rich_formatted_date" }, { "data-date", Data::NumberToString(text.date) }, { "data-day-of-week", RichBoolAttribute(text.dayOfWeek) }, { "data-long-date", RichBoolAttribute(text.longDate) }, { "data-long-time", RichBoolAttribute(text.longTime) }, { "data-relative", RichBoolAttribute(text.relative) }, { "data-short-date", RichBoolAttribute(text.shortDate) }, { "data-short-time", RichBoolAttribute(text.shortTime) }, { "datetime", datetime.toString(Qt::ISODate).toUtf8() }, { "dir", "ltr" }, { "inline", QByteArray() }, { "title", Data::FormatDateTime(text.date) }, }); result += renderTextChildren(text); result += _context.popTag(); return result; } case Type::InlineImage: { auto result = _context.pushTag("span", { { "class", "rich_inline_fallback" }, { "data-document-id", Data::NumberToString(text.id) }, { "data-rich-kind", "inline-image" }, { "data-source-height", Data::NumberToString(text.height) }, { "data-source-width", Data::NumberToString(text.width) }, { "inline", QByteArray() }, }); result += "Inline image"; result += _context.popTag(); return result; } case Type::Diff: { auto result = _context.pushTag("span", { { "class", "rich_diff" }, { "data-rich-kind", "diff" }, { "inline", QByteArray() }, }); result += renderTextChildren(text); result += _context.pushTag("del", { { "class", "rich_diff_previous" }, { "inline", QByteArray() }, }); result += "Previous: "; if (text.oldChildren.empty()) { result += _context.pushTag("span", { { "class", "rich_inline_fallback" }, { "data-rich-kind", "unsupported-text" }, { "inline", QByteArray() }, }); result += "Unsupported rich text"; result += _context.popTag(); } else { result += renderTexts(text.oldChildren); } result += _context.popTag(); result += _context.popTag(); return result; } } Unexpected("Type in RichHtmlRenderer::renderText."); } QByteArray RichHtmlRenderer::renderBlocks( const std::vector &blocks) { auto result = QByteArray(); for (const auto &block : blocks) { result += renderBlock(block); } return result; } QByteArray RichHtmlRenderer::renderTextBlock( const QByteArray &tag, std::map attributes, const Data::RichText &text) { auto result = _context.pushTag(tag, std::move(attributes)); result += renderText(text); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderAuthorDate( const Data::RichBlock &block) { auto attributes = RichBlockAttributes("rich_author_date", "author-date"); attributes.emplace("dir", "auto"); auto result = _context.pushTag("address", std::move(attributes)); result += renderText(block.text); if (block.date) { const auto datetime = QDateTime::fromSecsSinceEpoch(block.date); result += " \xE2\x80\xA2 "; result += _context.pushTag("time", { { "data-date", Data::NumberToString(block.date) }, { "datetime", datetime.toString(Qt::ISODate).toUtf8() }, { "dir", "ltr" }, { "inline", QByteArray() }, }); result += SerializeString(Data::FormatDateTime(block.date)); result += _context.popTag(); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderCode(const Data::RichBlock &block) { auto attributes = RichBlockAttributes("rich_code", "code"); attributes.emplace("dir", "auto"); attributes.emplace("inline", QByteArray()); if (!block.language.isEmpty()) { attributes.emplace("data-language", block.language); } auto result = _context.pushTag("pre", std::move(attributes)); result += _context.pushTag("code", { { "class", "rich_code_content" }, { "inline", QByteArray() }, }); result += renderText(block.text); result += _context.popTag(); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderList(const Data::RichBlock &block) { using Kind = Data::RichListKind; const auto ordered = (block.listKind == Kind::Ordered); auto attributes = RichBlockAttributes( "rich_list", ordered ? QByteArray("ordered-list") : QByteArray("list")); if (ordered) { if (block.orderedList.reversed) { attributes.emplace("reversed", QByteArray()); } if (block.orderedList.start) { attributes.emplace( "start", Data::NumberToString(*block.orderedList.start)); } if (block.orderedList.type) { const auto &type = *block.orderedList.type; auto native = false; if (type.size() == 1) { switch (type[0]) { case '1': case 'a': case 'A': case 'i': case 'I': native = true; break; } } attributes.emplace( native ? QByteArray("type") : QByteArray("data-list-type"), type); } } auto result = _context.pushTag( ordered ? QByteArray("ol") : QByteArray("ul"), std::move(attributes)); for (const auto &item : block.listItems) { result += renderListItem(item, ordered); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderListItem( const Data::RichListItem &item, bool ordered) { using Content = Data::RichListItemContent; using State = Data::RichTaskState; const auto task = (item.taskState != State::None); const auto checked = (item.taskState == State::Checked); const auto customMarker = ordered && !task && item.num && !item.num->isEmpty(); auto classes = QByteArray("rich_list_item"); if (task) { classes += " rich_task_item"; } else if (customMarker) { classes += " rich_order_item"; } auto attributes = std::map{ { "class", std::move(classes) }, { "data-rich-content", (item.content == Content::Blocks) ? QByteArray("blocks") : QByteArray("text") }, }; if (item.num) { attributes.emplace("data-num", *item.num); } if (item.type) { attributes.emplace("data-item-type", *item.type); } if (item.value) { attributes.emplace("value", Data::NumberToString(*item.value)); } auto result = _context.pushTag("li", std::move(attributes)); if (task) { result += _context.pushTag("span", { { "aria-checked", RichBoolAttribute(checked) }, { "class", "rich_task_marker" }, { "inline", QByteArray() }, { "role", "checkbox" }, }); result += checked ? "\xE2\x98\x91" : "\xE2\x98\x90"; result += _context.popTag(); } else if (customMarker) { result += _context.pushTag("span", { { "class", "rich_order_marker" }, { "inline", QByteArray() }, }); result += SerializeString(*item.num); result += _context.popTag(); } result += _context.pushTag( (item.content == Content::Blocks) ? "div" : "span", { { "class", "rich_list_content" }, { "inline", QByteArray() }, }); switch (item.content) { case Content::Text: if (item.text) { result += renderText(*item.text); } break; case Content::Blocks: result += renderBlocks(item.blocks); break; } result += _context.popTag(); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderQuote(const Data::RichBlock &block) { using Content = Data::RichQuoteContent; const auto kind = block.pullquote ? QByteArray("pullquote") : (block.quoteContent == Content::Blocks) ? QByteArray("quote-blocks") : QByteArray("quote"); const auto classes = block.pullquote ? QByteArray("rich_quote rich_pullquote") : QByteArray("rich_quote"); auto result = _context.pushTag( block.pullquote ? QByteArray("aside") : QByteArray("blockquote"), RichBlockAttributes(classes, kind)); result += _context.pushTag("div", { { "class", "rich_quote_body" }, }); switch (block.quoteContent) { case Content::Text: result += renderText(block.text); break; case Content::Blocks: result += renderBlocks(block.blocks); break; } result += _context.popTag(); const auto caption = renderText(block.quoteCaption); if (!caption.isEmpty()) { result += _context.pushTag("cite", { { "class", "rich_quote_caption" }, }); result += caption; result += _context.popTag(); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderDetails(const Data::RichBlock &block) { auto attributes = RichBlockAttributes("rich_details", "details"); if (block.open) { attributes.emplace("open", QByteArray()); } auto result = _context.pushTag("details", std::move(attributes)); result += _context.pushTag("summary", {}); result += renderText(block.text); result += _context.popTag(); result += _context.pushTag("div", { { "class", "rich_details_body" }, }); result += renderBlocks(block.blocks); result += _context.popTag(); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderTable(const Data::RichBlock &block) { auto wrapperAttributes = RichBlockAttributes("rich_table_wrap", "table"); wrapperAttributes.emplace("tabindex", "0"); auto result = _context.pushTag("div", std::move(wrapperAttributes)); auto classes = QByteArray("rich_table"); if (block.bordered) { classes += " bordered"; } if (block.striped) { classes += " striped"; } result += _context.pushTag("table", { { "class", std::move(classes) }, }); const auto caption = renderText(block.text); if (!caption.isEmpty()) { result += _context.pushTag("caption", {}); result += caption; result += _context.popTag(); } result += _context.pushTag("tbody", {}); for (const auto &row : block.tableRows) { result += renderTableRow(row); } result += _context.popTag(); result += _context.popTag(); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderTableRow( const Data::RichTableRow &row) { auto result = _context.pushTag("tr", {}); for (const auto &cell : row.cells) { result += renderTableCell(cell); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderTableCell( const Data::RichTableCell &cell) { using Alignment = Data::RichTableAlignment; using VerticalAlignment = Data::RichTableVerticalAlignment; auto horizontalClass = QByteArray("rich_align_left"); switch (cell.alignment) { case Alignment::Left: break; case Alignment::Center: horizontalClass = "rich_align_center"; break; case Alignment::Right: horizontalClass = "rich_align_right"; break; } auto verticalClass = QByteArray("rich_valign_top"); switch (cell.verticalAlignment) { case VerticalAlignment::Top: break; case VerticalAlignment::Middle: verticalClass = "rich_valign_middle"; break; case VerticalAlignment::Bottom: verticalClass = "rich_valign_bottom"; break; } auto attributes = std::map{ { "class", horizontalClass + ' ' + verticalClass }, }; if (cell.colspan && *cell.colspan > 0) { const auto source = *cell.colspan; const auto span = (source > kRichTableSpanMax) ? kRichTableSpanMax : source; attributes.emplace("colspan", Data::NumberToString(span)); if (span != source) { attributes.emplace( "data-source-colspan", Data::NumberToString(source)); } } if (cell.rowspan && *cell.rowspan > 0) { const auto source = *cell.rowspan; const auto span = (source > kRichTableSpanMax) ? kRichTableSpanMax : source; attributes.emplace("rowspan", Data::NumberToString(span)); if (span != source) { attributes.emplace( "data-source-rowspan", Data::NumberToString(source)); } } auto result = _context.pushTag( cell.header ? QByteArray("th") : QByteArray("td"), std::move(attributes)); if (cell.text) { result += renderText(*cell.text); } result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderFallback( const QByteArray &kind, const QByteArray &label) { auto result = _context.pushTag( "div", RichBlockAttributes("rich_fallback", kind)); result += SerializeString(label); result += _context.popTag(); return result; } QByteArray RichHtmlRenderer::renderBlock(const Data::RichBlock &block) { using Kind = Data::RichBlock::Kind; switch (block.kind) { case Kind::Unsupported: return renderFallback("unsupported", "Unsupported rich content"); case Kind::Heading: { auto attributes = RichBlockAttributes("rich_heading", "heading"); attributes.emplace( "data-level", Data::NumberToString(block.headingLevel)); return renderTextBlock( RichHeadingTag(block.headingLevel), std::move(attributes), block.text); } case Kind::Paragraph: { auto attributes = RichBlockAttributes("rich_paragraph", "paragraph"); attributes.emplace("dir", "auto"); return renderTextBlock("p", std::move(attributes), block.text); } case Kind::Footer: { auto attributes = RichBlockAttributes("rich_footer", "footer"); attributes.emplace("dir", "auto"); return renderTextBlock("footer", std::move(attributes), block.text); } case Kind::Thinking: { auto attributes = RichBlockAttributes("rich_thinking", "thinking"); attributes.emplace("dir", "auto"); return renderTextBlock("p", std::move(attributes), block.text); } case Kind::AuthorDate: return renderAuthorDate(block); case Kind::Code: return renderCode(block); case Kind::Divider: { auto attributes = RichBlockAttributes("rich_divider", "divider"); attributes.emplace("empty", QByteArray()); return _context.pushTag("hr", std::move(attributes)); } case Kind::Anchor: { const auto id = blockAnchorId(block); if (!id) { return renderFallback("malformed", "Malformed rich content"); } auto attributes = RichBlockAttributes("rich_anchor", "anchor"); attributes.emplace("id", *id); attributes.emplace("inline", QByteArray()); auto result = _context.pushTag("span", std::move(attributes)); result += _context.popTag(); return result; } case Kind::List: return renderList(block); case Kind::Quote: return renderQuote(block); case Kind::Photo: return renderPhoto(block); case Kind::Video: return renderVideo(block); case Kind::Cover: return renderMediaGroup(block, "cover", false); case Kind::Embed: return renderEmbed(block); case Kind::EmbedPost: return renderEmbedPost(block); case Kind::Collage: return renderMediaGroup(block, "collage", true); case Kind::Slideshow: return renderMediaGroup(block, "slideshow", true); case Kind::Channel: return renderChannel(block); case Kind::Audio: return renderAudio(block); case Kind::Math: { auto attributes = RichBlockAttributes("rich_math_display", "math"); attributes.emplace("dir", "ltr"); attributes.emplace("inline", QByteArray()); auto result = _context.pushTag("div", std::move(attributes)); result += SerializeString(block.formula); result += _context.popTag(); return result; } case Kind::Table: return renderTable(block); case Kind::Details: return renderDetails(block); case Kind::RelatedArticles: return renderRelatedArticles(block); case Kind::Map: return renderMap(block, "map"); case Kind::InputMap: return renderMap(block, "input-map"); case Kind::Unknown: return renderFallback("unknown", "Unknown rich content"); } Unexpected("Kind in RichHtmlRenderer::renderBlock."); } [[nodiscard]] QByteArray RenderRichMessage( Context &context, const Data::RichMessage &message, int messageId, const QString &internalLinksDomain, const QByteArray &relativeBase, const RichMediaCallbacks &media) { return RichHtmlRenderer( context, message, messageId, internalLinksDomain, relativeBase, media).renderMessage(); } } // namespace struct HtmlWriter::MessageInfo { enum class Type { Service, Default, }; int id = 0; Type type = Type::Service; PeerId fromId = 0; UserId viaBotId = 0; TimeId date = 0; PeerId forwardedFromId = 0; QString forwardedFromName; bool forwarded = false; bool showForwardedAsOriginal = false; TimeId forwardedDate = 0; }; class HtmlWriter::Wrap { public: Wrap(const QString &path, const QString &base, Stats *stats); [[nodiscard]] bool empty() const; [[nodiscard]] QByteArray pushTag( const QByteArray &tag, std::map &&attributes = {}); [[nodiscard]] QByteArray popTag(); [[nodiscard]] QByteArray indent() const; [[nodiscard]] QByteArray pushDiv( const QByteArray &className, const QByteArray &style = {}); [[nodiscard]] QByteArray pushUserpic(const UserpicData &userpic); [[nodiscard]] QByteArray pushListEntry( const UserpicData &userpic, const QByteArray &name, const QByteArray &details, const QByteArray &info, const QString &link = QString()); [[nodiscard]] QByteArray pushStoriesListEntry( const StoryData &story, const QByteArray &name, const QByteArrayList &details, const QByteArray &info, const std::vector &caption, const QString &internalLinksDomain, const QString &link = QString()); [[nodiscard]] QByteArray pushAudioEntry( const QByteArray &name, const QByteArray &info, const QByteArrayList &details, const QByteArray &duration, const QString &link = QString()); [[nodiscard]] QByteArray pushSessionListEntry( int apiId, const QByteArray &name, const QByteArray &subname, std::initializer_list details, const QByteArray &info = QByteArray()); [[nodiscard]] QByteArray pushHeader( const QByteArray &header, const QString &path = QString()); [[nodiscard]] QByteArray pushSection( const QByteArray &label, const QByteArray &type, int count, const QString &path); [[nodiscard]] QByteArray pushAbout( const QByteArray &text, bool withDivider = false); [[nodiscard]] QByteArray pushServiceMessage( int messageId, const Data::DialogInfo &dialog, const QString &basePath, const QByteArray &text, const Data::Photo *photo = nullptr); [[nodiscard]] std::pair pushMessage( const Data::Message &message, const MessageInfo *previous, const Data::DialogInfo &dialog, const QString &basePath, const PeersMap &peers, const QString &internalLinksDomain, Fn wrapMessageLink); [[nodiscard]] Result writeBlock(const QByteArray &block); [[nodiscard]] Result close(); [[nodiscard]] QString relativePath(const QString &path) const; [[nodiscard]] QString relativePath(const Data::File &file) const; ~Wrap(); private: [[nodiscard]] QByteArray composeStart(); [[nodiscard]] QByteArray pushGenericListEntry( const QString &link, const UserpicData &userpic, const QByteArray &name, const QByteArray &subname, std::initializer_list details, const QByteArray &info); [[nodiscard]] bool messageNeedsWrap( const Data::Message &message, const MessageInfo *previous) const; [[nodiscard]] bool forwardedNeedsWrap( const Data::Message &message, const MessageInfo *previous) const; [[nodiscard]] MediaData prepareMediaData( const Data::Message &message, const QString &basePath, const PeersMap &peers, const QString &internalLinksDomain) const; [[nodiscard]] QByteArray pushMedia( const Data::Message &message, const QString &basePath, const PeersMap &peers, const QString &internalLinksDomain, Fn wrapMessageLink); [[nodiscard]] QByteArray pushGenericMedia(const MediaData &data); [[nodiscard]] QByteArray pushRichPhotoMedia( const Data::Photo *data, const QString &basePath); [[nodiscard]] QByteArray pushRichVideoMedia( const Data::Document *data, const QString &basePath); [[nodiscard]] QByteArray pushRichAudioMedia( const Data::Document *data); [[nodiscard]] QByteArray pushRichReferenceMedia( MediaData data, const Data::Photo *photo, const QString &basePath); [[nodiscard]] QByteArray pushStickerMedia( const Data::Document &data, const QString &basePath); [[nodiscard]] QByteArray pushAnimatedMedia( const Data::Document &data, const QString &basePath); [[nodiscard]] QByteArray pushVideoFileMedia( const Data::Document &data, const QString &basePath); [[nodiscard]] QByteArray pushPhotoMedia( const Data::Photo &data, const QString &basePath); [[nodiscard]] QByteArray pushPoll( const Data::Poll &data, const QString &internalLinksDomain, const QString &relativeLinkBase); [[nodiscard]] QByteArray pushTodoList( const Data::TodoList &data, const QString &internalLinksDomain, const QString &relativeLinkBase); [[nodiscard]] QByteArray pushGiveaway( const PeersMap &peers, const Data::GiveawayStart &data); [[nodiscard]] QByteArray pushGiveaway( const PeersMap &peers, const Data::GiveawayResults &data, Fn wrapMessageLink); File _file; QByteArray _composedStart; bool _closed = false; QByteArray _base; Context _context; }; struct HtmlWriter::SavedSection { int priority = 0; QByteArray label; QByteArray type; int count = 0; QString path; }; void FillUserpicNames(UserpicData &data, const Data::Peer &peer) { if (peer.user()) { data.firstName = peer.user()->info.firstName; data.lastName = peer.user()->info.lastName; } else if (peer.chat()) { data.firstName = peer.name(); } } void FillUserpicNames(UserpicData &data, const QByteArray &full) { const auto names = full.split(' '); data.firstName = names[0]; for (auto i = 1; i != names.size(); ++i) { if (names[i].isEmpty()) { continue; } if (!data.lastName.isEmpty()) { data.lastName.append(' '); } data.lastName.append(names[i]); } } QByteArray ComposeName(const UserpicData &data, const QByteArray &empty) { return ((data.firstName.isEmpty() && data.lastName.isEmpty()) ? empty : (data.firstName + ' ' + data.lastName)); } QString WriteUserpicThumb( const QString &basePath, const QString &largePath, const UserpicData &userpic, const QString &postfix = "_thumb") { return Data::WriteImageThumb( basePath, largePath, userpic.pixelSize * 2, userpic.pixelSize * 2, postfix); } HtmlWriter::Wrap::Wrap( const QString &path, const QString &base, Stats *stats) : _file(path, stats) { Expects(base.endsWith('/')); Expects(path.startsWith(base)); const auto left = path.mid(base.size()); const auto nesting = ranges::count(left, '/'); _base = QString("../").repeated(nesting).toUtf8(); _composedStart = composeStart(); } bool HtmlWriter::Wrap::empty() const { return _file.empty(); } QByteArray HtmlWriter::Wrap::pushTag( const QByteArray &tag, std::map &&attributes) { return _context.pushTag(tag, std::move(attributes)); } QByteArray HtmlWriter::Wrap::popTag() { return _context.popTag(); } QByteArray HtmlWriter::Wrap::indent() const { return _context.indent(); } QByteArray HtmlWriter::Wrap::pushDiv( const QByteArray &className, const QByteArray &style) { return style.isEmpty() ? _context.pushTag("div", { { "class", className } }) : _context.pushTag("div", { { "class", className }, { "style", style } }); } QByteArray HtmlWriter::Wrap::pushUserpic(const UserpicData &userpic) { const auto size = Data::NumberToString(userpic.pixelSize) + "px"; auto result = QByteArray(); if (!userpic.largeLink.isEmpty()) { result.append(pushTag("a", { { "class", "userpic_link" }, { "href", relativePath(userpic.largeLink).toUtf8() } })); } const auto sizeStyle = "width: " + size + "; height: " + size; if (!userpic.imageLink.isEmpty()) { result.append(pushTag("img", { { "class", "userpic" }, { "style", sizeStyle }, { "src", relativePath(userpic.imageLink).toUtf8() }, { "empty", "" } })); } else { result.append(pushTag("div", { { "class", "userpic userpic" + Data::NumberToString(userpic.colorIndex + 1) }, { "style", sizeStyle } })); if (userpic.tooltip.isEmpty()) { result.append(pushDiv( "initials", "line-height: " + size)); } else { result.append(pushTag("div", { { "class", "initials" }, { "style", "line-height: " + size }, { "title", userpic.tooltip }, })); } auto character = [](const QByteArray &from) { const auto utf = QString::fromUtf8(from).trimmed(); return utf.isEmpty() ? QByteArray() : SerializeString(utf.mid(0, 1).toUtf8()); }; result.append(character(userpic.firstName)); result.append(character(userpic.lastName)); result.append(popTag()); result.append(popTag()); } if (!userpic.largeLink.isEmpty()) { result.append(popTag()); } return result; } QByteArray HtmlWriter::Wrap::pushListEntry( const UserpicData &userpic, const QByteArray &name, const QByteArray &details, const QByteArray &info, const QString &link) { return pushGenericListEntry( link, userpic, name, {}, { details }, info); } QByteArray HtmlWriter::Wrap::pushStoriesListEntry( const StoryData &story, const QByteArray &name, const QByteArrayList &details, const QByteArray &info, const std::vector &caption, const QString &internalLinksDomain, const QString &link) { auto result = pushDiv("entry clearfix"); if (!link.isEmpty()) { result.append(pushTag("a", { { "class", "pull_left userpic_wrap" }, { "href", relativePath(link).toUtf8() + "#allow_back" }, })); } else { result.append(pushDiv("pull_left userpic_wrap")); } if (!story.imageLink.isEmpty()) { const auto sizeStyle = "width: " + Data::NumberToString(kStoryThumbWidth) + "px; height: " + Data::NumberToString(kStoryThumbHeight) + "px"; result.append(pushTag("img", { { "class", "story" }, { "style", sizeStyle }, { "src", relativePath(story.imageLink).toUtf8() }, { "empty", "" } })); } result.append(popTag()); result.append(pushDiv("body")); if (!info.isEmpty()) { result.append(pushDiv("pull_right info details")); result.append(SerializeString(info)); result.append(popTag()); } if (!name.isEmpty()) { if (!link.isEmpty()) { result.append(pushTag("a", { { "class", "block_link expanded" }, { "href", relativePath(link).toUtf8() + "#allow_back" }, })); } result.append(pushDiv("name bold")); result.append(SerializeString(name)); result.append(popTag()); if (!link.isEmpty()) { result.append(popTag()); } } const auto text = caption.empty() ? QByteArray() : FormatText(caption, internalLinksDomain, _base); if (!text.isEmpty()) { result.append(pushDiv("text")); result.append(text); result.append(popTag()); } for (const auto &detail : details) { result.append(pushDiv("details_entry details")); result.append(SerializeString(detail)); result.append(popTag()); } result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushAudioEntry( const QByteArray &name, const QByteArray &info, const QByteArrayList &details, const QByteArray &duration, const QString &link) { auto result = pushDiv("entry clearfix"); if (!link.isEmpty()) { result.append(pushTag("a", { { "class", "pull_left userpic_wrap" }, { "href", relativePath(link).toUtf8() + "#allow_back" }, })); } else { result.append(pushDiv("pull_left userpic_wrap")); } result.append(pushDiv("userpic audio_icon")); result.append(popTag()); result.append(popTag()); result.append(pushDiv("body")); if (!duration.isEmpty()) { result.append(pushDiv("pull_right info details")); result.append(SerializeString(duration)); result.append(popTag()); } if (!info.isEmpty()) { if (!link.isEmpty()) { result.append(pushTag("a", { { "class", "block_link expanded" }, { "href", relativePath(link).toUtf8() + "#allow_back" }, })); } result.append(pushDiv("name bold")); result.append(SerializeString(info)); result.append(popTag()); if (!link.isEmpty()) { result.append(popTag()); } } for (const auto &detail : details) { result.append(pushDiv("details_entry details")); result.append(SerializeString(detail)); result.append(popTag()); } result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushSessionListEntry( int apiId, const QByteArray &name, const QByteArray &subname, std::initializer_list details, const QByteArray &info) { const auto link = QString(); auto userpic = UserpicData{ Data::ApplicationColorIndex(apiId), kEntryUserpicSize }; userpic.firstName = name; return pushGenericListEntry( link, userpic, name, subname, details, info); } QByteArray HtmlWriter::Wrap::pushGenericListEntry( const QString &link, const UserpicData &userpic, const QByteArray &name, const QByteArray &subname, std::initializer_list details, const QByteArray &info) { auto result = link.isEmpty() ? pushDiv("entry clearfix") : pushTag("a", { { "class", "entry block_link clearfix" }, { "href", relativePath(link).toUtf8() + "#allow_back" }, }); result.append(pushDiv("pull_left userpic_wrap")); result.append(pushUserpic(userpic)); result.append(popTag()); result.append(pushDiv("body")); if (!info.isEmpty()) { result.append(pushDiv("pull_right info details")); result.append(SerializeString(info)); result.append(popTag()); } if (!name.isEmpty()) { result.append(pushDiv("name bold")); result.append(SerializeString(name)); result.append(popTag()); } if (!subname.isEmpty()) { result.append(pushDiv("subname bold")); result.append(SerializeString(subname)); result.append(popTag()); } for (const auto &detail : details) { result.append(pushDiv("details_entry details")); result.append(SerializeString(detail)); result.append(popTag()); } result.append(popTag()); result.append(popTag()); return result; } Result HtmlWriter::Wrap::writeBlock(const QByteArray &block) { Expects(!_closed); const auto result = [&] { if (block.isEmpty()) { return _file.writeBlock(block); } else if (_file.empty()) { return _file.writeBlock(_composedStart + block); } return _file.writeBlock(block); }(); if (!result) { _closed = true; } return result; } QByteArray HtmlWriter::Wrap::pushHeader( const QByteArray &header, const QString &path) { auto result = pushDiv("page_header"); result.append(path.isEmpty() ? pushDiv("content") : pushTag("a", { { "class", "content block_link" }, { "href", relativePath(path).toUtf8() }, { "onclick", "return GoBack(this)"}, })); result.append(pushDiv("text bold")); result.append(SerializeString(header)); result.append(popTag()); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushSection( const QByteArray &header, const QByteArray &type, int count, const QString &link) { auto result = pushTag("a", { { "class", "section block_link " + type }, { "href", link.toUtf8() + "#allow_back" }, }); result.append(pushDiv("counter details")); result.append(Data::NumberToString(count)); result.append(popTag()); result.append(pushDiv("label bold")); result.append(SerializeString(header)); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushAbout( const QByteArray &text, bool withDivider) { auto result = pushDiv(withDivider ? "page_about details with_divider" : "page_about details"); result.append(MakeLinks(SerializeString(text))); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushServiceMessage( int messageId, const Data::DialogInfo &dialog, const QString &basePath, const QByteArray &serialized, const Data::Photo *photo) { auto result = pushTag("div", { { "class", "message service" }, { "id", "message" + Data::NumberToString(messageId) } }); result.append(pushDiv("body details")); result.append(serialized); result.append(popTag()); if (photo) { auto userpic = UserpicData(); userpic.colorIndex = dialog.colorIndex; userpic.firstName = dialog.name; userpic.lastName = dialog.lastName; userpic.pixelSize = kServiceMessagePhotoSize; userpic.largeLink = photo->image.file.relativePath; userpic.imageLink = WriteUserpicThumb( basePath, userpic.largeLink, userpic); result.append(pushDiv("userpic_wrap")); result.append(pushUserpic(userpic)); result.append(popTag()); } result.append(popTag()); return result; } auto HtmlWriter::Wrap::pushMessage( const Data::Message &message, const MessageInfo *previous, const Data::DialogInfo &dialog, const QString &basePath, const PeersMap &peers, const QString &internalLinksDomain, Fn wrapMessageLink ) -> std::pair { using namespace Data; auto info = MessageInfo(); info.id = message.id; info.fromId = message.fromId; info.viaBotId = message.viaBotId; info.date = message.date; info.forwardedFromId = message.forwardedFromId; info.forwardedFromName = message.forwardedFromName; info.forwardedDate = message.forwardedDate; info.forwarded = message.forwarded; info.showForwardedAsOriginal = message.showForwardedAsOriginal; if (v::is(message.media.content)) { return { info, pushServiceMessage( message.id, dialog, basePath, "This message is not supported by this version " "of Telegram Desktop. Please update the application.") }; } const auto wrapReplyToLink = [&](const QByteArray &text) { return wrapMessageLink(message.replyToMsgId, text); }; using DialogType = Data::DialogInfo::Type; const auto isChannel = (dialog.type == DialogType::PrivateChannel) || (dialog.type == DialogType::PublicChannel); const auto serviceFrom = peers.wrapPeerName(message.fromId); const auto serviceText = v::match(message.action.content, [&]( const ActionChatCreate &data) { return serviceFrom + " created group «" + SerializeString(data.title) + "»" + (data.userIds.empty() ? QByteArray() : " with members " + peers.wrapUserNames(data.userIds)); }, [&](const ActionChatEditTitle &data) { return isChannel ? ("Channel title changed to «" + SerializeString(data.title) + "»") : (serviceFrom + " changed group title to «" + SerializeString(data.title) + "»"); }, [&](const ActionChatEditPhoto &data) { return isChannel ? QByteArray("Channel photo changed") : (serviceFrom + " changed group photo"); }, [&](const ActionChatDeletePhoto &data) { return isChannel ? QByteArray("Channel photo removed") : (serviceFrom + " removed group photo"); }, [&](const ActionChatAddUser &data) { return serviceFrom + " invited " + peers.wrapUserNames(data.userIds); }, [&](const ActionChatDeleteUser &data) { return serviceFrom + " removed " + peers.wrapUserName(data.userId); }, [&](const ActionChatJoinedByLink &data) { return serviceFrom + " joined group by link from " + peers.wrapUserName(data.inviterId); }, [&](const ActionChannelCreate &data) { return "Channel «" + SerializeString(data.title) + "» created"; }, [&](const ActionChatMigrateTo &data) { return serviceFrom + " converted this group to a supergroup"; }, [&](const ActionChannelMigrateFrom &data) { return serviceFrom + " converted a basic group to this supergroup " + "«" + SerializeString(data.title) + "»"; }, [&](const ActionPinMessage &data) { return serviceFrom + " pinned " + wrapReplyToLink("this message"); }, [&](const ActionHistoryClear &data) { return QByteArray("History cleared"); }, [&](const ActionGameScore &data) { return serviceFrom + " scored " + NumberToString(data.score) + " in " + wrapReplyToLink("this game"); }, [&](const ActionPaymentSent &data) { const auto amount = FormatMoneyAmount(data.amount, data.currency); if (data.recurringUsed) { return "You were charged " + amount + " via recurring payment"; } auto result = "You have successfully transferred " + amount + " for " + wrapReplyToLink("this invoice"); if (data.recurringInit) { result += " and allowed future recurring payments"; } return result; }, [&](const ActionPhoneCall &data) { return QByteArray(); }, [&](const ActionScreenshotTaken &data) { return serviceFrom + " took a screenshot"; }, [&](const ActionCustomAction &data) { return data.message; }, [&](const ActionBotAllowed &data) { return data.attachMenu ? "You allowed this bot to message you " "when you added it in the attachment menu."_q : data.fromRequest ? "You allowed this bot to message you in his web-app."_q : data.app.isEmpty() ? ("You allowed this bot to message you when you opened " + SerializeString(data.app)) : ("You allowed this bot to message you when you logged in on " + SerializeString(data.domain)); }, [&](const ActionSecureValuesSent &data) { auto list = std::vector(); for (const auto type : data.types) { list.push_back([&] { using Type = ActionSecureValuesSent::Type; switch (type) { case Type::PersonalDetails: return "Personal details"; case Type::Passport: return "Passport"; case Type::DriverLicense: return "Driver license"; case Type::IdentityCard: return "Identity card"; case Type::InternalPassport: return "Internal passport"; case Type::Address: return "Address information"; case Type::UtilityBill: return "Utility bill"; case Type::BankStatement: return "Bank statement"; case Type::RentalAgreement: return "Rental agreement"; case Type::PassportRegistration: return "Passport registration"; case Type::TemporaryRegistration: return "Temporary registration"; case Type::Phone: return "Phone number"; case Type::Email: return "Email"; } return ""; }()); } return "You have sent the following documents: " + SerializeList(list); }, [&](const ActionContactSignUp &data) { return serviceFrom + " joined Telegram"; }, [&](const ActionGeoProximityReached &data) { const auto fromName = peers.wrapPeerName(data.fromId); const auto toName = peers.wrapPeerName(data.toId); const auto distance = [&]() -> QString { if (data.distance >= 1000) { const auto km = (10 * (data.distance / 10)) / 1000.; return QString::number(km) + " km"; } else if (data.distance == 1) { return "1 meter"; } else { return QString::number(data.distance) + " meters"; } }().toUtf8(); if (data.fromSelf) { return "You are now within " + distance + " from " + toName; } else if (data.toSelf) { return fromName + " is now within " + distance + " from you"; } else { return fromName + " is now within " + distance + " from " + toName; } }, [&](const ActionPhoneNumberRequest &data) { return serviceFrom + " requested your phone number"; }, [&](const ActionGroupCall &data) { const auto durationText = (data.duration ? (" (" + QString::number(data.duration) + " seconds)") : QString()).toUtf8(); return isChannel ? ("Voice chat" + durationText) : (serviceFrom + " started voice chat" + durationText); }, [&](const ActionInviteToGroupCall &data) { return serviceFrom + " invited " + peers.wrapUserNames(data.userIds) + " to the voice chat"; }, [&](const ActionSetMessagesTTL &data) { const auto periodText = (data.period == 7 * 86400) ? "7 days" : (data.period == 86400) ? "24 hours" : QByteArray(); return isChannel ? (data.period ? "New messages will auto-delete in " + periodText : "New messages will not auto-delete") : (data.period ? (serviceFrom + " has set messages to auto-delete in " + periodText) : (serviceFrom + " has set messages not to auto-delete")); }, [&](const ActionGroupCallScheduled &data) { const auto dateText = FormatDateTime(data.date); return isChannel ? ("Voice chat scheduled for " + dateText) : (serviceFrom + " scheduled a voice chat for " + dateText); }, [&](const ActionSetChatTheme &data) { if (data.emoji.isEmpty()) { return isChannel ? "Channel theme was disabled" : (serviceFrom + " disabled chat theme"); } return isChannel ? ("Channel theme was changed to " + data.emoji).toUtf8() : (serviceFrom + " changed chat theme to " + data.emoji).toUtf8(); }, [&](const ActionChatJoinedByRequest &data) { return serviceFrom + " joined group by request"; }, [&](const ActionWebViewDataSent &data) { return "You have just successfully transferred data from the «" + SerializeString(data.text) + "» button to the bot"; }, [&](const ActionGiftPremium &data) { if (!data.days || data.cost.isEmpty()) { return serviceFrom + " sent you a gift."; } return serviceFrom + " sent you a gift for " + data.cost + ": Telegram Premium for " + QString::number(data.days).toUtf8() + " days."; }, [&](const ActionTopicCreate &data) { return serviceFrom + " created topic «" + SerializeString(data.title) + "»"; }, [&](const ActionTopicEdit &data) { auto parts = QList(); if (!data.title.isEmpty()) { parts.push_back("title to «" + SerializeString(data.title) + "»"); } if (data.iconEmojiId) { parts.push_back("icon to «" + QString::number(*data.iconEmojiId).toUtf8() + "»"); } return serviceFrom + " changed topic " + parts.join(','); }, [&](const ActionSuggestProfilePhoto &data) { return serviceFrom + " suggests to use this photo"; }, [&](const ActionRequestedPeer &data) { return "requested: "_q/* + data.peerId*/; }, [&](const ActionSetChatWallPaper &data) { return serviceFrom + (data.same ? (" set " + wrapReplyToLink("the same background") + " for this chat") : " set a new background for this chat"); }, [&](const ActionGiftCode &data) { return data.unclaimed ? ("This is an unclaimed Telegram Premium for " + NumberToString(data.days) + (data.days > 1 ? " days" : " day") + " prize in a giveaway organized by a channel.") : data.viaGiveaway ? ("You won a Telegram Premium for " + NumberToString(data.days) + (data.days > 1 ? " days" : " day") + " prize in a giveaway organized by a channel.") : ("You've received a Telegram Premium for " + NumberToString(data.days) + (data.days > 1 ? " days" : " day") + " gift from a channel."); }, [&](const ActionGiveawayLaunch &data) { return serviceFrom + " just started a giveaway " "of Telegram Premium subscriptions to its followers."; }, [&](const ActionGiveawayResults &data) { return !data.winners ? "No winners of the giveaway could be selected." : (data.credits && data.unclaimed) ? "Some winners of the giveaway were randomly selected by " "Telegram and received their prize." : (!data.credits && data.unclaimed) ? "Some winners of the giveaway were randomly selected by " "Telegram and received private messages with giftcodes." : (data.credits && !data.unclaimed) ? NumberToString(data.winners) + " of the giveaway was randomly " "selected by Telegram and received their prize." : NumberToString(data.winners) + " of the giveaway was randomly " "selected by Telegram and received private messages with " "giftcodes."; }, [&](const ActionBoostApply &data) { return serviceFrom + " boosted the group " + QByteArray::number(data.boosts) + (data.boosts > 1 ? " times" : " time"); }, [&](const ActionPaymentRefunded &data) { const auto amount = FormatMoneyAmount(data.amount, data.currency); auto result = peers.wrapPeerName(data.peerId) + " refunded back " + amount; return result; }, [&](const ActionGiftCredits &data) { if (!data.amount || data.cost.isEmpty()) { return serviceFrom + " sent you a gift."; } return serviceFrom + " sent you a gift for " + data.cost + ": " + QString::number(data.amount.value()).toUtf8() + (data.amount.ton() ? " TON." : " Telegram Stars."); }, [&](const ActionPrizeStars &data) { return "You won a prize in a giveaway organized by " + peers.wrapPeerName(data.peerId) + ".\n Your prize is " + QString::number(data.amount).toUtf8() + " Telegram Stars."; }, [&](const ActionStarGift &data) { return serviceFrom + " sent you a gift of " + QByteArray::number(data.stars) + " Telegram Stars."; }, [&](const ActionPaidMessagesRefunded &data) { auto result = message.out ? ("You refunded " + QString::number(data.stars).toUtf8() + " Stars for " + QString::number(data.messages).toUtf8() + " messages to " + peers.wrapPeerName(dialog.peerId)) : (peers.wrapPeerName(dialog.peerId) + " refunded " + QString::number(data.stars).toUtf8() + " Stars for " + QString::number(data.messages).toUtf8() + " messages to you"); return result; }, [&](const ActionPaidMessagesPrice &data) { if (isChannel) { auto result = !data.broadcastAllowed ? "Direct messages were disabled." : ("Price per direct message changed to " + QString::number(data.stars).toUtf8() + " Telegram Stars."); return result; } auto result = "Price per message changed to " + QString::number(data.stars).toUtf8() + " Telegram Stars."; return result; }, [&](const ActionTodoCompletions &data) { auto completed = QByteArrayList(); for (const auto index : data.completed) { completed.push_back(QByteArray::number(index)); } auto incompleted = QByteArrayList(); for (const auto index : data.incompleted) { incompleted.push_back(QByteArray::number(index)); } const auto list = [](const QByteArrayList &v) { return v.isEmpty() ? QByteArray() : (v.size() > 1) ? (v.mid(0, v.size() - 1).join(", ") + " and " + v.back()) : v.front(); }; if (completed.isEmpty() && !incompleted.isEmpty()) { return serviceFrom + " marked " + list(incompleted) + " as not done yet in " + wrapReplyToLink("this todo list") + "."; } else if (!completed.isEmpty() && incompleted.isEmpty()) { return serviceFrom + " marked " + list(completed) + " as done in " + wrapReplyToLink("this todo list") + "."; } return serviceFrom + " marked " + list(completed) + " as done and " + list(incompleted) + " as not done yet in " + wrapReplyToLink("this todo list") + "."; }, [&](const ActionTodoAppendTasks &data) { auto tasks = QByteArrayList(); for (const auto &task : data.items) { tasks.push_back(""" + FormatText(task.text, internalLinksDomain, _base) + """); } return serviceFrom + " added tasks: " + tasks.join(", "); }, [&](const ActionPollAppendAnswer &data) { return serviceFrom + " added "" + data.option + "" to the poll."; }, [&](const ActionPollDeleteAnswer &data) { return serviceFrom + " removed "" + data.option + "" from the poll."; }, [&](const ActionSuggestedPostApproval &data) { return serviceFrom + (data.rejected ? " rejected " : " approved ") + "your suggested post" + (data.price ? (", for " + QString::number(data.price.value()).toUtf8() + (data.price.ton() ? " TON" : " stars")) : "") + (data.scheduleDate ? (", " + FormatDateText(data.scheduleDate) + " at " + FormatTimeText(data.scheduleDate)) : "") + (data.rejectComment.isEmpty() ? "." : (", with comment: "" + SerializeString(data.rejectComment) + """)); }, [&](const ActionSuggestedPostSuccess &data) { return "The paid post was shown for 24 hours and " + QString::number(data.price.value()).toUtf8() + (data.price.ton() ? " TON" : " stars") + " were transferred to the channel."; }, [&](const ActionSuggestedPostRefund &data) { return QByteArray() + (data.payerInitiated ? "The user refunded the payment, post was deleted." : "The admin deleted the post early, the payment was refunded."); }, [&](const ActionSuggestBirthday &data) { return serviceFrom + " suggests to add a date of birth: " + QByteArray::number(data.birthday.day()) + [&] { switch (data.birthday.month()) { case 1: return " January"; case 2: return " February"; case 3: return " March"; case 4: return " April"; case 5: return " May"; case 6: return " June"; case 7: return " July"; case 8: return " August"; case 9: return " September"; case 10: return " October"; case 11: return " November"; case 12: return " December"; } return ""; }() + (data.birthday.year() ? (' ' + QByteArray::number(data.birthday.year())) : QByteArray()); }, [&](const ActionNoForwardsToggle &data) { return serviceFrom + (data.newValue ? " disabled sharing in this chat" : " enabled sharing in this chat"); }, [&](const ActionNoForwardsRequest &data) { return serviceFrom + " requested to enable sharing in this chat"; }, [&](const ActionNewCreatorPending &data) { return peers.wrapUserName(data.newCreatorId) + " will become the new main admin in 7 days if " + serviceFrom + " does not return"; }, [&](const ActionChangeCreator &data) { return serviceFrom + " made " + peers.wrapUserName(data.newCreatorId) + " the new main admin of the group"; }, [&](const ActionManagedBotCreated &data) { return serviceFrom + " created a bot " + peers.wrapUserName(data.botId); }, [](v::null_t) { return QByteArray(); }); if (!serviceText.isEmpty()) { const auto &content = message.action.content; const auto photo = v::is(content) ? &v::get(content).photo : v::is(content) ? &v::get(content).photo : nullptr; return { info, pushServiceMessage( message.id, dialog, basePath, serviceText, photo) }; } info.type = MessageInfo::Type::Default; const auto wrap = messageNeedsWrap(message, previous); const auto fromPeerId = message.fromId; const auto showForwardedInfo = message.forwarded && !message.showForwardedAsOriginal; auto forwardedUserpic = UserpicData(); if (message.forwarded) { forwardedUserpic.colorIndex = message.forwardedFromId ? PeerColorIndex(message.forwardedFromId) : PeerColorIndex(message.id); forwardedUserpic.pixelSize = kHistoryUserpicSize; if (message.forwardedFromId) { FillUserpicNames( forwardedUserpic, peers.peer(message.forwardedFromId)); } else { FillUserpicNames(forwardedUserpic, message.forwardedFromName); } } auto userpic = UserpicData(); if (message.showForwardedAsOriginal) { userpic = forwardedUserpic; } else { userpic.colorIndex = PeerColorIndex(fromPeerId); userpic.pixelSize = kHistoryUserpicSize; FillUserpicNames(userpic, peers.peer(fromPeerId)); } const auto via = [&] { if (message.viaBotId) { const auto &user = peers.user(message.viaBotId); if (!user.username.isEmpty()) { return SerializeString(user.username); } } return QByteArray(); }(); const auto className = wrap ? "message default clearfix" : "message default clearfix joined"; auto block = pushTag("div", { { "class", className }, { "id", "message" + NumberToString(message.id) } }); if (wrap) { block.append(pushDiv("pull_left userpic_wrap")); block.append(pushUserpic(userpic)); block.append(popTag()); } block.append(pushDiv("body")); block.append(pushTag("div", { { "class", "pull_right date details" }, { "title", FormatDateTime(message.date, true) }, })); block.append(FormatTimeText(message.date)); block.append(popTag()); if (wrap) { block.append(pushDiv("from_name")); block.append(SerializeString( ComposeName(userpic, "Deleted Account"))); if (!via.isEmpty() && (!message.forwarded || message.showForwardedAsOriginal)) { block.append(" via @" + via); } block.append(popTag()); } if (showForwardedInfo) { const auto forwardedWrap = forwardedNeedsWrap(message, previous); if (forwardedWrap) { block.append(pushDiv("pull_left forwarded userpic_wrap")); block.append(pushUserpic(forwardedUserpic)); block.append(popTag()); } block.append(pushDiv("forwarded body")); if (forwardedWrap) { block.append(pushDiv("from_name")); block.append(SerializeString( ComposeName(forwardedUserpic, "Deleted Account"))); if (!via.isEmpty()) { block.append(" via @" + via); } block.append(pushTag("span", { { "class", "date details" }, { "title", FormatDateTime(message.forwardedDate, true) }, { "inline", "" } })); block.append(' ' + FormatDateTime(message.forwardedDate)); block.append(popTag()); block.append(popTag()); } } if (message.replyToMsgId) { block.append(pushDiv("reply_to details")); if (message.replyToPeerId) { block.append("In reply to a message in another chat"); } else { block.append("In reply to "); block.append(wrapReplyToLink("this message")); } block.append(popTag()); } block.append( pushMedia( message, basePath, peers, internalLinksDomain, wrapMessageLink)); if (message.richMessage) { const auto callbacks = RichMediaCallbacks{ .photo = [this, basePath](const Data::Photo *photo) { return pushRichPhotoMedia(photo, basePath); }, .video = [this, basePath](const Data::Document *document) { return pushRichVideoMedia(document, basePath); }, .audio = [this](const Data::Document *document) { return pushRichAudioMedia(document); }, .generic = [this](const MediaData &data) { return pushGenericMedia(data); }, .photoCard = [this, basePath]( const MediaData &data, const Data::Photo *photo) { return pushRichReferenceMedia(data, photo, basePath); }, }; block.append(RenderRichMessage( _context, *message.richMessage, message.id, internalLinksDomain, _base, callbacks)); } else { const auto text = FormatText(message.text, internalLinksDomain, _base); if (!text.isEmpty()) { block.append(pushDiv("text")); block.append(text); block.append(popTag()); } } if (!message.inlineButtonRows.empty()) { using Type = HistoryMessageMarkupButton::Type; const auto endline = u" | "_q; block.append(pushTag("table", { { "class", "bot_buttons_table" } })); block.append(pushTag("tbody")); for (const auto &row : message.inlineButtonRows) { block.append(pushTag("tr")); block.append(pushTag("td", { { "class", "bot_button_row" } })); for (const auto &button : row) { using Attribute = std::pair; const auto content = (!button.data.isEmpty() ? (u"Data: "_q + button.data + endline) : QString()) + (!button.forwardText.isEmpty() ? (u"Forward text: "_q + button.forwardText + endline) : QString()) + (u"Type: "_q + HistoryMessageMarkupButton::TypeToString(button)); const auto link = (button.type == Type::Url) ? button.data : QByteArray(); const auto onclick = (button.type != Type::Url) ? ("return ShowTextCopied('" + QString(content) .replace('\\', u"\\\\"_q) .replace('\'', u"\\'"_q) + "');").toUtf8() : QByteArray(); block.append(pushTag("div", { { "class", "bot_button" } })); block.append(pushTag("a", { link.isEmpty() ? Attribute() : Attribute{ "href", link }, onclick.isEmpty() ? Attribute() : Attribute{ "onclick", onclick }, })); block.append(pushTag("div")); block.append(SerializeString(button.text.toUtf8())); block.append(popTag()); block.append(popTag()); block.append(popTag()); if (&button != &row.back()) { block.append(pushTag("div", { { "class", "bot_button_column_separator" } })); block.append(popTag()); } } block.append(popTag()); block.append(popTag()); } block.append(popTag()); block.append(popTag()); } if (!message.signature.isEmpty()) { block.append(pushDiv("signature details")); block.append(SerializeString(message.signature)); block.append(popTag()); } if (showForwardedInfo) { block.append(popTag()); } if (!message.reactions.empty()) { block.append(pushTag("span", { { "class", "reactions" }, })); for (const auto &reaction : message.reactions) { auto reactionClass = QByteArray("reaction"); for (const auto &recent : reaction.recent) { const auto peer = peers.peer(recent.peerId); if (peer.user() && peer.user()->isSelf) { reactionClass += " active"; break; } } if (reaction.type == Reaction::Type::Paid) { reactionClass += " paid"; } block.append(pushTag("span", { { "class", reactionClass }, })); block.append(pushTag("span", { { "class", "emoji" }, })); switch (reaction.type) { case Reaction::Type::Emoji: block.append(SerializeString(reaction.emoji.toUtf8())); break; case Reaction::Type::CustomEmoji: block.append(FormatCustomEmoji( reaction.documentId, "\U0001F44B", _base)); break; case Reaction::Type::Paid: block.append(SerializeString("\u2B50")); break; } block.append(popTag()); if (!reaction.recent.empty()) { block.append(pushTag("span", { { "class", "userpics" }, })); for (const auto &recent : reaction.recent) { const auto peer = peers.peer(recent.peerId); block.append(pushUserpic(UserpicData({ .colorIndex = peer.colorIndex(), .pixelSize = 20, .firstName = peer.user() ? peer.user()->info.firstName : peer.name(), .lastName = peer.user() ? peer.user()->info.lastName : "", .tooltip = peer.name(), }))); } block.append(popTag()); } if (reaction.recent.empty() || (reaction.count > reaction.recent.size())) { block.append(pushTag("span", { { "class", "count" }, })); block.append(NumberToString(reaction.count)); block.append(popTag()); } block.append(popTag()); } block.append(popTag()); } block.append(popTag()); block.append(popTag()); return { info, block }; } bool HtmlWriter::Wrap::messageNeedsWrap( const Data::Message &message, const MessageInfo *previous) const { if (!previous) { return true; } else if (previous->type != MessageInfo::Type::Default) { return true; } else if (!message.fromId || previous->fromId != message.fromId) { return true; } else if (message.viaBotId != previous->viaBotId) { return true; } else if (QDateTime::fromSecsSinceEpoch(previous->date).date() != QDateTime::fromSecsSinceEpoch(message.date).date()) { return true; } else if (message.forwarded != previous->forwarded || message.showForwardedAsOriginal != previous->showForwardedAsOriginal || message.forwardedFromId != previous->forwardedFromId || message.forwardedFromName != previous->forwardedFromName) { return true; } else if (std::abs(message.date - previous->date) > ((message.forwardedFromId || !message.forwardedFromName.isEmpty()) ? 1 : kJoinWithinSeconds)) { return true; } return false; } QByteArray HtmlWriter::Wrap::pushMedia( const Data::Message &message, const QString &basePath, const PeersMap &peers, const QString &internalLinksDomain, Fn wrapMessageLink) { const auto data = prepareMediaData( message, basePath, peers, internalLinksDomain); if (!data.classes.isEmpty()) { return pushGenericMedia(data); } using namespace Data; const auto &content = message.media.content; if (const auto document = std::get_if(&content)) { Assert(!message.media.ttl); if (document->isSticker) { return pushStickerMedia(*document, basePath); } else if (document->isAnimated) { return pushAnimatedMedia(*document, basePath); } else if (document->isVideoFile) { return pushVideoFileMedia(*document, basePath); } Unexpected("Non generic document in HtmlWriter::Wrap::pushMedia."); } else if (const auto photo = std::get_if(&content)) { Assert(!message.media.ttl); return pushPhotoMedia(*photo, basePath); } else if (const auto poll = std::get_if(&content)) { return pushPoll(*poll, internalLinksDomain, _base); } else if (const auto todo = std::get_if(&content)) { return pushTodoList(*todo, internalLinksDomain, _base); } else if (const auto giveaway = std::get_if(&content)) { return pushGiveaway(peers, *giveaway); } else if (const auto giveaway = std::get_if(&content)) { return pushGiveaway(peers, *giveaway, wrapMessageLink); } Assert(v::is_null(content)); return QByteArray(); } QByteArray HtmlWriter::Wrap::pushGenericMedia(const MediaData &data) { auto result = pushDiv("media_wrap clearfix"); if (data.link.isEmpty()) { result.append(pushDiv("media clearfix pull_left " + data.classes)); } else { result.append(pushTag("a", { { "class", "media clearfix pull_left block_link " + data.classes }, { "href", (IsGlobalLink(data.link) ? data.link.toUtf8() : relativePath(data.link).toUtf8()) } })); } if (data.thumb.isEmpty()) { result.append(pushDiv("fill pull_left")); result.append(popTag()); } else { result.append(pushTag("img", { { "class", "thumb pull_left" }, { "src", relativePath(data.thumb).toUtf8() }, { "empty", "" } })); } result.append(pushDiv("body")); if (!data.title.isEmpty()) { result.append(pushDiv("title bold")); result.append(SerializeString(data.title)); result.append(popTag()); } if (!data.description.isEmpty()) { result.append(pushDiv("description")); result.append(SerializeString(data.description)); result.append(popTag()); } if (!data.status.isEmpty()) { result.append(pushDiv("status details")); result.append(SerializeString(data.status)); result.append(popTag()); } result.append(popTag()); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushStickerMedia( const Data::Document &data, const QString &basePath) { using namespace Data; const auto &[thumb, size] = WriteImageThumb( basePath, data.file.relativePath, CalculateThumbSize( kStickerMaxWidth, kStickerMaxHeight, kStickerMinWidth, kStickerMinHeight), "PNG", -1); if (thumb.isEmpty()) { auto generic = MediaData(); generic.title = "Sticker"; generic.status = data.stickerEmoji; if (data.file.relativePath.isEmpty()) { if (!generic.status.isEmpty()) { generic.status += ", "; } generic.status += FormatFileSize(data.file.size); } else { generic.link = data.file.relativePath; } generic.description = NoFileDescription(data.file.skipReason); generic.classes = "media_photo"; return pushGenericMedia(generic); } auto result = pushDiv("media_wrap clearfix"); result.append(pushTag("a", { { "class", "sticker_wrap clearfix pull_left" }, { "href", relativePath(data.file.relativePath).toUtf8() } })); const auto sizeStyle = "width: " + NumberToString(size.width() / 2) + "px; height: " + NumberToString(size.height() / 2) + "px"; result.append(pushTag("img", { { "class", "sticker" }, { "style", sizeStyle }, { "src", relativePath(thumb).toUtf8() }, { "empty", "" } })); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushAnimatedMedia( const Data::Document &data, const QString &basePath) { using namespace Data; auto size = QSize(data.width, data.height); auto thumbSize = CalculateThumbSize( kPhotoMaxWidth, kPhotoMaxHeight, kPhotoMinWidth, kPhotoMinHeight, true)(size); if (data.thumb.file.relativePath.isEmpty() || data.file.relativePath.isEmpty() || !thumbSize.width() || !thumbSize.height()) { auto generic = MediaData(); generic.title = "Animation"; generic.status = FormatFileSize(data.file.size); generic.link = data.file.relativePath; generic.description = NoFileDescription(data.file.skipReason); generic.classes = "media_video"; return pushGenericMedia(generic); } auto result = pushDiv("media_wrap clearfix"); result.append(pushTag("a", { { "class", "animated_wrap clearfix pull_left" }, { "href", relativePath(data.file.relativePath).toUtf8() } })); result.append(pushDiv("video_play_bg")); result.append(pushDiv("gif_play")); result.append("GIF"); result.append(popTag()); result.append(popTag()); const auto sizeStyle = "width: " + NumberToString(thumbSize.width() / 2) + "px; height: " + NumberToString(thumbSize.height() / 2) + "px"; result.append(pushTag("img", { { "class", "animated" }, { "style", sizeStyle }, { "src", relativePath(data.thumb.file.relativePath).toUtf8() }, { "empty", "" } })); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushVideoFileMedia( const Data::Document &data, const QString &basePath) { using namespace Data; auto size = QSize(data.width, data.height); auto thumbSize = CalculateThumbSize( kPhotoMaxWidth, kPhotoMaxHeight, kPhotoMinWidth, kPhotoMinHeight, true)(size); if (data.thumb.file.relativePath.isEmpty() || data.file.relativePath.isEmpty() || !thumbSize.width() || !thumbSize.height()) { auto generic = MediaData(); generic.title = "Video file"; generic.status = FormatDuration(data.duration); if (data.file.relativePath.isEmpty()) { generic.status += ", " + FormatFileSize(data.file.size); } else { generic.link = data.file.relativePath; } generic.description = NoFileDescription(data.file.skipReason); generic.classes = "media_video"; return pushGenericMedia(generic); } auto result = pushDiv("media_wrap clearfix"); result.append(pushTag("a", { { "class", "video_file_wrap clearfix pull_left" }, { "href", relativePath(data.file.relativePath).toUtf8() } })); result.append(pushDiv("video_play_bg")); result.append(pushDiv("video_play")); result.append(popTag()); result.append(popTag()); result.append(pushDiv("video_duration")); result.append(FormatDuration(data.duration)); result.append(popTag()); const auto sizeStyle = "width: " + NumberToString(thumbSize.width() / 2) + "px; height: " + NumberToString(thumbSize.height() / 2) + "px"; result.append(pushTag("img", { { "class", "video_file" }, { "style", sizeStyle }, { "src", relativePath(data.thumb.file.relativePath).toUtf8() }, { "empty", "" } })); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushPhotoMedia( const Data::Photo &data, const QString &basePath) { using namespace Data; const auto &[thumb, size] = WriteImageThumb( basePath, data.image.file.relativePath, CalculateThumbSize( kPhotoMaxWidth, kPhotoMaxHeight, kPhotoMinWidth, kPhotoMinHeight)); if (thumb.isEmpty()) { auto generic = MediaData(); generic.title = "Photo"; generic.status = Ui::FormatImageSizeText( QSize(data.image.width, data.image.height)).toUtf8(); if (data.image.file.relativePath.isEmpty()) { generic.status += ", " + FormatFileSize(data.image.file.size); } else { generic.link = data.image.file.relativePath; } generic.description = NoFileDescription(data.image.file.skipReason); generic.classes = "media_photo"; return pushGenericMedia(generic); } auto result = pushDiv("media_wrap clearfix"); result.append(pushTag("a", { { "class", "photo_wrap clearfix pull_left" }, { "href", relativePath(data.image.file.relativePath).toUtf8() } })); const auto sizeStyle = "width: " + NumberToString(size.width() / 2) + "px; height: " + NumberToString(size.height() / 2) + "px"; result.append(pushTag("img", { { "class", "photo" }, { "style", sizeStyle }, { "src", relativePath(thumb).toUtf8() }, { "empty", "" } })); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushRichPhotoMedia( const Data::Photo *data, const QString &basePath) { const auto presentation = RichPhotoPresentation(data); return pushPhotoMedia(presentation, basePath); } QByteArray HtmlWriter::Wrap::pushRichVideoMedia( const Data::Document *data, const QString &basePath) { auto presentation = RichDocumentPresentation(data); const auto &thumbPath = presentation.thumb.file.relativePath; if (!thumbPath.isEmpty()) { QImageReader reader(basePath + thumbPath); if (!reader.canRead() || reader.read().isNull()) { presentation.thumb.file.relativePath.clear(); } } return pushVideoFileMedia(presentation, basePath); } QByteArray HtmlWriter::Wrap::pushRichAudioMedia( const Data::Document *data) { const auto presentation = RichDocumentPresentation(data); return pushGenericMedia(PrepareAudioMediaData(presentation)); } QByteArray HtmlWriter::Wrap::pushRichReferenceMedia( MediaData data, const Data::Photo *photo, const QString &basePath) { const auto presentation = RichPhotoPresentation(photo); const auto &path = presentation.image.file.relativePath; data.link = path; data.thumb = path.isEmpty() ? QString() : Data::WriteImageThumb( basePath, path, kEntryUserpicSize * 2, kEntryUserpicSize * 2, u"_rich_card_thumb"_q); return pushGenericMedia(data); } QByteArray HtmlWriter::Wrap::pushPoll( const Data::Poll &data, const QString &internalLinksDomain, const QString &relativeLinkBase) { using namespace Data; auto result = pushDiv("media_wrap clearfix"); result.append(pushDiv("media_poll")); result.append(pushDiv("question bold")); result.append(FormatText( data.question, internalLinksDomain, relativeLinkBase)); result.append(popTag()); result.append(pushDiv("details")); if (data.closed) { result.append(SerializeString("Final results")); } else { result.append(SerializeString("Anonymous poll")); } result.append(popTag()); const auto votes = [](int count) { if (count > 1) { return NumberToString(count) + " votes"; } else if (count > 0) { return NumberToString(count) + " vote"; } return QByteArray("No votes"); }; const auto details = [&](const Poll::Answer &answer) { if (!answer.votes) { return QByteArray(""); } else if (!answer.my) { return " " + votes(answer.votes) + ""; } return " " + votes(answer.votes) + ", chosen vote"; }; for (const auto &answer : data.answers) { result.append(pushDiv("answer")); result.append("- " + FormatText(answer.text, internalLinksDomain, relativeLinkBase) + details(answer)); result.append(popTag()); } result.append(pushDiv("total details ")); result.append(votes(data.totalVotes)); result.append(popTag()); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushTodoList( const Data::TodoList &data, const QString &internalLinksDomain, const QString &relativeLinkBase) { using namespace Data; auto result = pushDiv("media_wrap clearfix"); result.append(pushDiv("media_poll")); result.append(pushDiv("question bold")); result.append(FormatText( data.title, internalLinksDomain, relativeLinkBase)); result.append(popTag()); result.append(pushDiv("details")); result.append(SerializeString("To-do List")); result.append(popTag()); const auto details = [&](const TodoListItem &item) { return QByteArray(""); // #TODO todo }; for (const auto &item : data.items) { result.append(pushDiv("answer")); result.append("- " + FormatText(item.text, internalLinksDomain, relativeLinkBase) + details(item)); result.append(popTag()); } result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushGiveaway( const PeersMap &peers, const Data::GiveawayStart &data) { auto result = pushDiv("media_wrap clearfix"); result.append(pushDiv("media_giveaway")); result.append(pushDiv("section_title bold")); result.append((data.quantity > 1) ? SerializeString("Giveaway Prizes") : SerializeString("Giveaway Prize")); result.append(popTag()); { result.append(pushDiv("section_body")); result.append("" + Data::NumberToString(data.quantity) + " " + SerializeString(data.additionalPrize.toUtf8())); result.append(popTag()); result.append(pushDiv("section_title bold")); result.append(SerializeString("with")); result.append(popTag()); }; result.append(pushDiv("section_body")); if (data.credits > 0) { result.append("" + Data::NumberToString(data.credits) + (SerializeString(data.credits == 1 ? (" Star") : (" Stars"))) + " " + SerializeString("will be distributed ") + ((data.quantity == 1) ? SerializeString("to ") + "" + Data::NumberToString(data.quantity) + " " + SerializeString("winner.") : SerializeString("among ") + "" + Data::NumberToString(data.quantity) + " " + SerializeString("winners."))); } else { result.append("" + Data::NumberToString(data.quantity) + " " + SerializeString((data.quantity > 1) ? "Telegram Premium Subscriptions" : "Telegram Premium Subscription") + " for " + Data::NumberToString(data.months) + " " + (data.months > 1 ? "months." : "month.")); } result.append(popTag()); result.append(pushDiv("section_title bold")); result.append(SerializeString("Participants")); result.append(popTag()); result.append(pushDiv("section_body")); auto channels = QByteArrayList(); auto anyChannel = false; auto anyGroup = false; for (const auto &channel : data.channels) { if (const auto chat = peers.peer(channel).chat()) { if (chat->isBroadcast) { anyChannel = true; } else if (chat->isSupergroup) { anyGroup = true; } } channels.append("" + peers.wrapPeerName(channel) + ""); } const auto participants = [&] { if (data.all && !anyGroup && anyChannel && channels.size() == 1) { return "All subscribers of the channel:"; } if (data.all && !anyGroup && anyChannel && channels.size() > 1) { return "All subscribers of the channels:"; } if (data.all && anyGroup && !anyChannel && channels.size() == 1) { return "All members of the group:"; } if (data.all && anyGroup && !anyChannel && channels.size() > 1) { return "All members of the groups:"; } if (data.all && anyGroup && anyChannel && channels.size() == 1) { return "All members of the group:"; } if (data.all && anyGroup && anyChannel && channels.size() > 1) { return "All members of the groups and channels:"; } if (!data.all && !anyGroup && anyChannel && channels.size() == 1) { return "All users who joined the channel below after this date:"; } if (!data.all && !anyGroup && anyChannel && channels.size() > 1) { return "All users who joined the channels below after this date:"; } if (!data.all && anyGroup && !anyChannel && channels.size() == 1) { return "All users who joined the group below after this date:"; } if (!data.all && anyGroup && !anyChannel && channels.size() > 1) { return "All users who joined the groups below after this date:"; } if (!data.all && anyGroup && anyChannel && channels.size() == 1) { return "All users who joined the group below after this date:"; } if (!data.all && anyGroup && anyChannel && channels.size() > 1) { return "All users who joined the groups and channels below " "after this date:"; } return ""; }(); result.append(SerializeString(participants)) + channels.join(", "); result.append(popTag()); { const auto &instance = Countries::Instance(); auto countries = QStringList(); for (const auto &country : data.countries) { const auto name = instance.countryNameByISO2(country); const auto flag = instance.flagEmojiByISO2(country); countries.push_back(flag + QChar(0xA0) + name); } if (const auto count = countries.size()) { auto united = countries.front(); for (auto i = 1; i != count; ++i) { united = ((i + 1 == count) ? u"%1 and %2"_q : u"%1, %2"_q).arg(united, countries[i]); } result.append(pushDiv("section_body")); result.append( SerializeString((u"from %1"_q).arg(united).toUtf8())); result.append(popTag()); } } result.append(pushDiv("section_title bold")); result.append(SerializeString("Winners Selection Date")); result.append(popTag()); result.append(pushDiv("section_body")); result.append(Data::FormatDateTime(data.untilDate)); result.append(popTag()); result.append(popTag()); result.append(popTag()); return result; } QByteArray HtmlWriter::Wrap::pushGiveaway( const PeersMap &peers, const Data::GiveawayResults &data, Fn wrapMessageLink) { auto result = pushDiv("media_wrap clearfix"); result.append(pushDiv("media_giveaway")); result.append(pushDiv("section_title bold")); result.append((data.winnersCount > 1) ? SerializeString("Winners Selected!") : SerializeString("Winner Selected!")); result.append(popTag()); result.append(pushDiv("section_body")); result.append( "" + Data::NumberToString(data.winnersCount) + " " + SerializeString((data.winnersCount > 1) ? "winners" : "winner") + " of the " + wrapMessageLink(data.launchId, "Giveaway") + " was randomly selected by Telegram."); result.append(popTag()); result.append(pushDiv("section_title bold")); result.append((data.winnersCount > 1) ? SerializeString("Winners") : SerializeString("Winner")); result.append(popTag()); result.append(pushDiv("section_body")); auto winners = QByteArrayList(); for (const auto &winner : data.winners) { winners.append("" + peers.wrapPeerName(winner) + ""); } const auto andMore = [&, size = data.winners.size()] { if (data.winnersCount > size) { return SerializeString(" and ") + Data::NumberToString(data.winnersCount - size) + SerializeString(" more!"); } return QByteArray(); }(); result.append(winners.join(", ") + andMore); result.append(popTag()); result.append(pushDiv("section_body")); const auto prize = [&, singleStar = (data.credits == 1)] { if (data.credits && data.winnersCount == 1) { return SerializeString("The winner received ") + "" + Data::NumberToString(data.credits) + "" + SerializeString(singleStar ? " Star." : " Stars."); } else if (data.credits && data.winnersCount > 1) { return SerializeString("All winners received ") + "" + Data::NumberToString(data.credits) + "" + SerializeString(singleStar ? " Star in total." : " Stars in total."); } else if (data.unclaimedCount) { return SerializeString("Some winners couldn't be selected."); } else if (data.winnersCount == 1) { return SerializeString( "The winner received their gift link in a private message."); } else if (data.winnersCount > 1) { return SerializeString( "All winners received gift links in private messages."); } return QByteArray(); }(); result.append(prize); result.append(popTag()); result.append(popTag()); result.append(popTag()); return result; } MediaData HtmlWriter::Wrap::prepareMediaData( const Data::Message &message, const QString &basePath, const PeersMap &peers, const QString &internalLinksDomain) const { using namespace Data; auto result = MediaData(); const auto &action = message.action; if (const auto call = std::get_if(&action.content)) { result.classes = "media_call"; result.title = peers.peer(message.out ? message.peerId : message.selfId).name(); result.status = [&] { using State = ActionPhoneCall::State; const auto state = call->state; if (state == State::Invitation) { return "Invitation"; } else if (state == State::Active) { return "Ongoing"; } else if (message.out) { return (state == State::Missed) ? "Cancelled" : "Outgoing"; } else if (state == State::Missed) { return "Missed"; } else if (state == State::Busy) { return "Declined"; } return "Incoming"; }(); if (call->duration > 0) { result.classes += " success"; result.status += " (" + NumberToString(call->duration) + " seconds)"; } return result; } v::match(message.media.content, [&](const Photo &data) { if (message.media.ttl) { result.title = "Self-destructing photo"; result.status = data.id ? "Please view it on your mobile" : "Expired"; result.classes = "media_photo"; return; } // At least try to pushPhotoMedia. }, [&](const Document &data) { if (message.media.ttl) { result.title = "Self-destructing video"; result.status = data.id ? "Please view it on your mobile" : "Expired"; result.classes = "media_video"; return; } const auto hasFile = !data.file.relativePath.isEmpty(); result.link = data.file.relativePath; result.description = NoFileDescription(data.file.skipReason); if (data.isSticker) { // At least try to pushStickerMedia. } else if (data.isVideoMessage) { result.title = "Video message"; result.status = FormatDuration(data.duration); if (!hasFile) { result.status += ", " + FormatFileSize(data.file.size); } result.thumb = data.thumb.file.relativePath; result.classes = "media_video"; } else if (data.isVoiceMessage) { result.title = "Voice message"; result.status = FormatDuration(data.duration); if (!hasFile) { result.status += ", " + FormatFileSize(data.file.size); } result.classes = "media_voice_message"; } else if (data.isAnimated) { // At least try to pushAnimatedMedia. } else if (data.isVideoFile) { // At least try to pushVideoFileMedia. } else if (data.isAudioFile) { result = PrepareAudioMediaData(data); } else { result.title = data.name.isEmpty() ? QByteArray("File") : data.name; result.status = FormatFileSize(data.file.size); result.classes = "media_file"; } }, [&](const SharedContact &data) { result.title = data.info.firstName + ' ' + data.info.lastName; result.classes = "media_contact"; result.status = FormatPhoneNumber(data.info.phoneNumber); if (!data.vcard.content.isEmpty()) { result.status += " - vCard"; result.link = data.vcard.relativePath; } }, [&](const GeoPoint &data) { if (message.media.ttl) { result.classes = "media_live_location"; result.title = "Live location"; result.status = ""; } else { result.classes = "media_location"; result.title = "Location"; } if (data.valid) { const auto latitude = NumberToString(data.latitude); const auto longitude = NumberToString(data.longitude); const auto coords = latitude + ',' + longitude; result.status = latitude + ", " + longitude; result.link = "https://maps.google.com/maps?q=" + coords + "&ll=" + coords + "&z=16"; } }, [&](const Venue &data) { result.classes = "media_venue"; result.title = data.title; result.description = data.address; if (data.point.valid) { const auto latitude = NumberToString(data.point.latitude); const auto longitude = NumberToString(data.point.longitude); const auto coords = latitude + ',' + longitude; result.link = "https://maps.google.com/maps?q=" + coords + "&ll=" + coords + "&z=16"; } }, [&](const Game &data) { result.classes = "media_game"; result.title = data.title; result.description = data.description; if (data.botId != 0 && !data.shortName.isEmpty()) { const auto bot = peers.user(data.botId); if (bot.isBot && !bot.username.isEmpty()) { const auto link = internalLinksDomain.toUtf8() + bot.username + "?game=" + data.shortName; result.link = link; result.status = link; } } }, [&](const Invoice &data) { result.classes = "media_invoice"; result.title = data.title; result.description = data.description; result.status = Data::FormatMoneyAmount(data.amount, data.currency); }, [](const Poll &data) { }, [](const TodoList &data) { }, [](const GiveawayStart &data) { }, [](const GiveawayResults &data) { }, [&](const PaidMedia &data) { result.classes = "media_invoice"; result.status = Data::FormatMoneyAmount(data.stars, "XTR"); }, [](const UnsupportedMedia &data) { Unexpected("Unsupported message."); }, [](v::null_t) {}); return result; } bool HtmlWriter::Wrap::forwardedNeedsWrap( const Data::Message &message, const MessageInfo *previous) const { Expects(message.forwarded); if (messageNeedsWrap(message, previous)) { return true; } else if (!message.forwardedFromId || message.forwardedFromId != previous->forwardedFromId) { return true; } else if (!peerIsUser(message.forwardedFromId)) { return true; } else if (abs(message.forwardedDate - previous->forwardedDate) > kJoinWithinSeconds) { return true; } return false; } Result HtmlWriter::Wrap::close() { if (!std::exchange(_closed, true) && !_file.empty()) { auto block = QByteArray(); while (!_context.empty()) { block.append(_context.popTag()); } return _file.writeBlock(block); } return Result::Success(); } QString HtmlWriter::Wrap::relativePath(const QString &path) const { return _base + path; } QString HtmlWriter::Wrap::relativePath(const Data::File &file) const { return relativePath(file.relativePath); } QByteArray HtmlWriter::Wrap::composeStart() { auto result = "" + _context.pushTag("html"); result.append(pushTag("head")); result.append(pushTag("meta", { { "charset", "utf-8" }, { "empty", "" } })); result.append(pushTag("title", { { "inline", "" } })); result.append("Exported Data"); result.append(popTag()); result.append(_context.pushTag("meta", { { "name", "viewport" }, { "content", "width=device-width, initial-scale=1.0" }, { "empty", "" } })); result.append(_context.pushTag("link", { { "href", _base + "css/style.css" }, { "rel", "stylesheet" }, { "empty", "" } })); result.append(_context.pushTag("script", { { "src", _base + "js/script.js" }, { "type", "text/javascript" }, })); result.append(_context.popTag()); result.append(popTag()); result.append(pushTag("body", { { "onload", "CheckLocation();" } })); result.append(pushDiv("page_wrap")); return result; } HtmlWriter::Wrap::~Wrap() { (void)close(); } HtmlWriter::HtmlWriter() = default; Result HtmlWriter::start( const Settings &settings, const Environment &environment, Stats *stats) { Expects(settings.path.endsWith('/')); _settings = base::duplicate(settings); _environment = environment; _stats = stats; //const auto result = copyFile( // ":/export/css/bootstrap.min.css", // "css/bootstrap.min.css"); //if (!result) { // return result; //} const auto copy = [&](const QString &filename) { return copyFile(":/export/" + filename, filename); }; const auto files = { "css/style.css", "images/back.png", "images/media_call.png", "images/media_contact.png", "images/media_file.png", "images/media_game.png", "images/media_location.png", "images/media_music.png", "images/media_photo.png", "images/media_shop.png", "images/media_video.png", "images/media_voice.png", "images/section_calls.png", "images/section_chats.png", "images/section_contacts.png", "images/section_frequent.png", "images/section_music.png", "images/section_other.png", "images/section_photos.png", "images/section_sessions.png", "images/section_stories.png", "images/section_web.png", "js/script.js", }; for (const auto path : files) { const auto name = QString(path); if (const auto result = copy(name); !result) { return result; } else if (const auto png = name.indexOf(".png"); png > 0) { const auto x2 = name.mid(0, png) + "@2x.png"; if (const auto result = copy(x2); !result) { return result; } } } if (_settings.onlySinglePeer()) { return Result::Success(); } _summary = fileWithRelativePath(mainFileRelativePath()); auto block = _summary->pushHeader("Exported Data"); block.append(_summary->pushDiv("page_body")); return _summary->writeBlock(block); } Result HtmlWriter::writePersonal(const Data::PersonalInfo &data) { Expects(_summary != nullptr); _selfColorIndex = data.user.info.colorIndex; if (_settings.types & Settings::Type::Userpics) { _delayedPersonalInfo = std::make_unique(data); return Result::Success(); } return writeDefaultPersonal(data); } Result HtmlWriter::writeDefaultPersonal(const Data::PersonalInfo &data) { return writePreparedPersonal(data, QString()); } Result HtmlWriter::writeDelayedPersonal(const QString &userpicPath) { if (!_delayedPersonalInfo) { return Result::Success(); } const auto result = writePreparedPersonal( *base::take(_delayedPersonalInfo), userpicPath); if (!result) { return result; } if (_userpicsCount) { pushUserpicsSection(); } return Result::Success(); } Result HtmlWriter::writePreparedPersonal( const Data::PersonalInfo &data, const QString &userpicPath) { Expects(_summary != nullptr); const auto &info = data.user.info; auto userpic = UserpicData{ _selfColorIndex, kPersonalUserpicSize }; userpic.largeLink = userpicPath.isEmpty() ? QString() : userpicsFilePath(); userpic.imageLink = WriteUserpicThumb( _settings.path, userpicPath, userpic, "_info"); userpic.firstName = info.firstName; userpic.lastName = info.lastName; auto block = _summary->pushDiv("personal_info clearfix"); block.append(_summary->pushDiv("pull_right userpic_wrap")); block.append(_summary->pushUserpic(userpic)); block.append(_summary->popTag()); const auto pushRows = [&]( QByteArray name, std::vector> &&values) { block.append(_summary->pushDiv("rows " + name)); for (const auto &[key, value] : values) { if (value.isEmpty()) { continue; } block.append(_summary->pushDiv("row")); block.append(_summary->pushDiv("label details")); block.append(SerializeString(key)); block.append(_summary->popTag()); block.append(_summary->pushDiv("value bold")); block.append(SerializeString(value)); block.append(_summary->popTag()); block.append(_summary->popTag()); } block.append(_summary->popTag()); }; pushRows("names", { { "First name", info.firstName }, { "Last name", info.lastName }, }); pushRows("info", { { "Phone number", Data::FormatPhoneNumber(info.phoneNumber) }, { "Username", FormatUsername(data.user.username) }, }); pushRows("bio", { { "Bio", data.bio } }); block.append(_summary->popTag()); _summaryNeedDivider = true; return _summary->writeBlock(block); } Result HtmlWriter::writeUserpicsStart(const Data::UserpicsInfo &data) { Expects(_summary != nullptr); Expects(_userpics == nullptr); _userpicsCount = data.count; if (!_userpicsCount) { return Result::Success(); } _userpics = fileWithRelativePath(userpicsFilePath()); auto block = _userpics->pushHeader( "Profile pictures", mainFileRelativePath()); block.append(_userpics->pushDiv("page_body list_page")); block.append(_userpics->pushDiv("entry_list")); if (const auto result = _userpics->writeBlock(block); !result) { return result; } if (!_delayedPersonalInfo) { pushUserpicsSection(); } return Result::Success(); } Result HtmlWriter::writeUserpicsSlice(const Data::UserpicsSlice &data) { Expects(_userpics != nullptr); Expects(!data.list.empty()); const auto firstPath = data.list.front().image.file.relativePath; if (const auto result = writeDelayedPersonal(firstPath); !result) { return result; } auto block = QByteArray(); for (const auto &userpic : data.list) { auto data = UserpicData{ _selfColorIndex, kEntryUserpicSize }; using SkipReason = Data::File::SkipReason; const auto &file = userpic.image.file; Assert(!file.relativePath.isEmpty() || file.skipReason != SkipReason::None); const auto status = [&]() -> Data::Utf8String { switch (file.skipReason) { case SkipReason::Unavailable: return "(Photo unavailable, please try again later)"; case SkipReason::FileSize: return "(Photo exceeds maximum size. " "Change data exporting settings to download.)"; case SkipReason::FileType: return "(Photo not included. " "Change data exporting settings to download.)"; case SkipReason::None: return Data::FormatFileSize(file.size); } Unexpected("Skip reason while writing photo path."); }(); const auto &path = userpic.image.file.relativePath; data.imageLink = WriteUserpicThumb(_settings.path, path, data); data.firstName = path.toUtf8(); block.append(_userpics->pushListEntry( data, (path.isEmpty() ? QString("Photo unavailable") : path).toUtf8(), status, (userpic.date > 0 ? Data::FormatDateTime(userpic.date) : QByteArray()), path)); } return _userpics->writeBlock(block); } Result HtmlWriter::writeUserpicsEnd() { if (const auto result = writeDelayedPersonal(QString()); !result) { return result; } else if (_userpics) { return base::take(_userpics)->close(); } return Result::Success(); } QString HtmlWriter::userpicsFilePath() const { return "lists/profile_pictures.html"; } void HtmlWriter::pushUserpicsSection() { pushSection( kUserpicsPriority, "Profile pictures", "photos", _userpicsCount, userpicsFilePath()); } Result HtmlWriter::writeStoriesStart(const Data::StoriesInfo &data) { Expects(_summary != nullptr); Expects(_stories == nullptr); _storiesCount = data.count; if (!_storiesCount) { return Result::Success(); } _stories = fileWithRelativePath(storiesFilePath()); auto block = _stories->pushHeader( "Stories archive", mainFileRelativePath()); block.append(_stories->pushDiv("page_body list_page")); block.append(_stories->pushDiv("entry_list")); if (const auto result = _stories->writeBlock(block); !result) { return result; } return Result::Success(); } Result HtmlWriter::writeStoriesSlice(const Data::StoriesSlice &data) { Expects(_stories != nullptr); _storiesCount -= data.skipped; if (data.list.empty()) { return Result::Success(); } auto block = QByteArray(); for (const auto &story : data.list) { auto data = StoryData{}; using SkipReason = Data::File::SkipReason; const auto &file = story.file(); Assert(!file.relativePath.isEmpty() || file.skipReason != SkipReason::None); auto status = QByteArrayList(); if (story.pinned) { status.append("Saved to Profile"); } if (story.expires > 0) { status.append("Expiring: " + Data::FormatDateTime(story.expires)); } status.append([&]() -> Data::Utf8String { switch (file.skipReason) { case SkipReason::Unavailable: return "(Story unavailable, please try again later)"; case SkipReason::FileSize: return "(Story exceeds maximum size. " "Change data exporting settings to download.)"; case SkipReason::FileType: return "(Story not included. " "Change data exporting settings to download.)"; case SkipReason::None: return Data::FormatFileSize(file.size); } Unexpected("Skip reason while writing story path."); }()); const auto &path = story.file().relativePath; const auto &image = story.thumb().file.relativePath.isEmpty() ? story.file().relativePath : story.thumb().file.relativePath; data.imageLink = Data::WriteImageThumb( _settings.path, image, kStoryThumbWidth * 2, kStoryThumbHeight * 2); const auto info = (story.date > 0) ? Data::FormatDateTime(story.date) : QByteArray(); block.append(_stories->pushStoriesListEntry( data, (path.isEmpty() ? QString("Story unavailable") : path).toUtf8(), status, info, story.caption, _environment.internalLinksDomain, path)); } return _stories->writeBlock(block); } Result HtmlWriter::writeStoriesEnd() { pushStoriesSection(); if (_stories) { return base::take(_stories)->close(); } return Result::Success(); } Result HtmlWriter::writeProfileMusicStart(const Data::ProfileMusicInfo &data) { Expects(_summary != nullptr); Expects(_profileMusic == nullptr); _profileMusicCount = data.count; if (!_profileMusicCount) { return Result::Success(); } _profileMusic = fileWithRelativePath(profileMusicFilePath()); auto block = _profileMusic->pushHeader( "Profile Music", mainFileRelativePath()); block.append(_profileMusic->pushDiv("page_body list_page")); block.append(_profileMusic->pushDiv("entry_list")); if (const auto result = _profileMusic->writeBlock(block); !result) { return result; } return Result::Success(); } Result HtmlWriter::writeProfileMusicSlice(const Data::ProfileMusicSlice &data) { Expects(_profileMusic != nullptr); _profileMusicCount -= data.skipped; if (data.list.empty()) { return Result::Success(); } auto block = QByteArray(); for (const auto &message : data.list) { if (!v::is(message.media.content)) { continue; } const auto &doc = v::get(message.media.content); if (!doc.isAudioFile) { continue; } using SkipReason = Data::File::SkipReason; const auto &file = doc.file; Assert(!file.relativePath.isEmpty() || file.skipReason != SkipReason::None); auto status = QByteArrayList(); status.append([&]() -> Data::Utf8String { switch (file.skipReason) { case SkipReason::Unavailable: return "(File unavailable, please try again later)"; case SkipReason::FileSize: return "(File exceeds maximum size. " "Change data exporting settings to download.)"; case SkipReason::FileType: return "(File not included. " "Change data exporting settings to download.)"; case SkipReason::None: return Data::FormatFileSize(file.size); } Unexpected("Skip reason while writing profile music path."); }()); const auto &path = file.relativePath; const auto title = !doc.songTitle.isEmpty() ? doc.songTitle : !doc.name.isEmpty() ? doc.name : Data::Utf8String("Unknown Track"); const auto performer = !doc.songPerformer.isEmpty() ? doc.songPerformer : Data::Utf8String("Unknown Artist"); const auto info = performer + " - " + title; const auto duration = doc.duration > 0 ? Data::FormatDuration(doc.duration) : QByteArray(); block.append(_profileMusic->pushAudioEntry( (path.isEmpty() ? QString("File unavailable") : path).toUtf8(), info, status, duration, path)); } return _profileMusic->writeBlock(block); } Result HtmlWriter::writeProfileMusicEnd() { pushProfileMusicSection(); if (_profileMusic) { return base::take(_profileMusic)->close(); } return Result::Success(); } QString HtmlWriter::storiesFilePath() const { return "lists/stories.html"; } void HtmlWriter::pushStoriesSection() { pushSection( kStoriesPriority, "Stories archive", "stories", _storiesCount, storiesFilePath()); } QString HtmlWriter::profileMusicFilePath() const { return "lists/profile_music.html"; } void HtmlWriter::pushProfileMusicSection() { pushSection( kProfileMusicPriority, "Profile Music", "music", _profileMusicCount, profileMusicFilePath()); } Result HtmlWriter::writeContactsList(const Data::ContactsList &data) { Expects(_summary != nullptr); if (const auto result = writeSavedContacts(data); !result) { return result; } else if (const auto result = writeFrequentContacts(data); !result) { return result; } return Result::Success(); } Result HtmlWriter::writeSavedContacts(const Data::ContactsList &data) { if (data.list.empty()) { return Result::Success(); } const auto filename = "lists/contacts.html"; const auto file = fileWithRelativePath(filename); auto block = file->pushHeader( "Contacts", mainFileRelativePath()); block.append(file->pushDiv("page_body list_page")); block.append(file->pushAbout(_environment.aboutContacts)); block.append(file->pushDiv("entry_list")); for (const auto index : Data::SortedContactsIndices(data)) { const auto &contact = data.list[index]; auto userpic = UserpicData{ Data::ContactColorIndex(contact), kEntryUserpicSize }; userpic.firstName = contact.firstName; userpic.lastName = contact.lastName; if (contact.userId) { const auto raw = contact.userId.bare & PeerId::kChatTypeMask; userpic.tooltip = (u"ID: "_q + QString::number(raw)).toUtf8(); } block.append(file->pushListEntry( userpic, ComposeName(userpic, "Deleted Account"), Data::FormatPhoneNumber(contact.phoneNumber), Data::FormatDateTime(contact.date))); } if (const auto result = file->writeBlock(block); !result) { return result; } else if (const auto closed = file->close(); !closed) { return closed; } pushSection( kContactsPriority, "Contacts", "contacts", data.list.size(), filename); return Result::Success(); } Result HtmlWriter::writeFrequentContacts(const Data::ContactsList &data) { const auto size = data.correspondents.size() + data.inlineBots.size() + data.phoneCalls.size(); if (!size) { return Result::Success(); } const auto filename = "lists/frequent.html"; const auto file = fileWithRelativePath(filename); auto block = file->pushHeader( "Frequent contacts", mainFileRelativePath()); block.append(file->pushDiv("page_body list_page")); block.append(file->pushAbout(_environment.aboutFrequent)); block.append(file->pushDiv("entry_list")); const auto writeList = [&]( const std::vector &peers, Data::Utf8String category) { for (const auto &top : peers) { const auto name = [&]() -> Data::Utf8String { if (top.peer.chat()) { return top.peer.name(); } else if (top.peer.user()->isSelf) { return "Saved messages"; } else { return top.peer.user()->info.firstName; } }(); const auto lastName = [&]() -> Data::Utf8String { if (top.peer.user() && !top.peer.user()->isSelf) { return top.peer.user()->info.lastName; } return {}; }(); auto userpic = UserpicData{ Data::PeerColorIndex(top.peer.id()), kEntryUserpicSize }; userpic.firstName = name; userpic.lastName = lastName; block.append(file->pushListEntry( userpic, ComposeName(userpic, "Deleted Account"), "Rating: " + Data::NumberToString(top.rating), category)); } }; writeList(data.correspondents, "people"); writeList(data.inlineBots, "inline bots"); writeList(data.phoneCalls, "calls"); if (const auto result = file->writeBlock(block); !result) { return result; } else if (const auto closed = file->close(); !closed) { return closed; } pushSection( kFrequentContactsPriority, "Frequent contacts", "frequent", size, filename); return Result::Success(); } Result HtmlWriter::writeSessionsList(const Data::SessionsList &data) { Expects(_summary != nullptr); if (const auto result = writeSessions(data); !result) { return result; } else if (const auto result = writeWebSessions(data); !result) { return result; } return Result::Success(); } Result HtmlWriter::writeSessions(const Data::SessionsList &data) { Expects(_summary != nullptr); if (data.list.empty()) { return Result::Success(); } const auto filename = "lists/sessions.html"; const auto file = fileWithRelativePath(filename); auto block = file->pushHeader( "Sessions", mainFileRelativePath()); block.append(file->pushDiv("page_body list_page")); block.append(file->pushAbout(_environment.aboutSessions)); block.append(file->pushDiv("entry_list")); for (const auto &session : data.list) { block.append(file->pushSessionListEntry( session.applicationId, ((session.applicationName.isEmpty() ? Data::Utf8String("Unknown") : session.applicationName) + ' ' + session.applicationVersion), (session.deviceModel + ", " + session.platform + ' ' + session.systemVersion), { (session.ip + " \xE2\x80\x93 " + session.region + ((session.region.isEmpty() || session.country.isEmpty()) ? QByteArray() : QByteArray(", ")) + session.country), "Last active: " + Data::FormatDateTime(session.lastActive), "Created: " + Data::FormatDateTime(session.created) })); } if (const auto result = file->writeBlock(block); !result) { return result; } else if (const auto closed = file->close(); !closed) { return closed; } pushSection( kSessionsPriority, "Sessions", "sessions", data.list.size(), filename); return Result::Success(); } Result HtmlWriter::writeWebSessions(const Data::SessionsList &data) { Expects(_summary != nullptr); if (data.webList.empty()) { return Result::Success(); } const auto filename = "lists/web_sessions.html"; const auto file = fileWithRelativePath(filename); auto block = file->pushHeader( "Web sessions", mainFileRelativePath()); block.append(file->pushDiv("page_body list_page")); block.append(file->pushAbout(_environment.aboutWebSessions)); block.append(file->pushDiv("entry_list")); for (const auto &session : data.webList) { block.append(file->pushSessionListEntry( Data::DomainApplicationId(session.domain), (session.domain.isEmpty() ? Data::Utf8String("Unknown") : session.domain), session.platform + ", " + session.browser, { session.ip + " \xE2\x80\x93 " + session.region, "Last active: " + Data::FormatDateTime(session.lastActive), "Created: " + Data::FormatDateTime(session.created) }, (session.botUsername.isEmpty() ? QByteArray() : ('@' + session.botUsername)))); } if (const auto result = file->writeBlock(block); !result) { return result; } else if (const auto closed = file->close(); !closed) { return closed; } pushSection( kWebSessionsPriority, "Web sessions", "web", data.webList.size(), filename); return Result::Success(); } Result HtmlWriter::writeOtherData(const Data::File &data) { Expects(_summary != nullptr); pushSection( kOtherPriority, "Other data", "other", 1, data.relativePath); return Result::Success(); } Result HtmlWriter::writeDialogsStart(const Data::DialogsInfo &data) { Expects(_chats == nullptr); if (data.chats.empty() && data.left.empty()) { return Result::Success(); } else if (_settings.onlySinglePeer()) { return Result::Success(); } _dialogsRelativePath = "lists/chats.html"; _chats = fileWithRelativePath(_dialogsRelativePath); auto block = _chats->pushHeader( "Chats", mainFileRelativePath()); block.append(_chats->pushDiv("page_body list_page")); if (const auto result = _chats->writeBlock(block); !result) { return result; } pushSection( kChatsPriority, "Chats", "chats", data.chats.size() + data.left.size(), "lists/chats.html"); return writeSections(); } Result HtmlWriter::writeDialogStart(const Data::DialogInfo &data) { Expects(_chat == nullptr); _chat = fileWithRelativePath(data.relativePath + messagesFile(0)); _chatFileEmpty = true; _messagesCount = 0; _dateMessageId = 0; _lastMessageInfo = nullptr; _lastMessageIdsPerFile.clear(); _dialog = data; return Result::Success(); } Result HtmlWriter::writeDialogSlice(const Data::MessagesSlice &data) { Expects(_chat != nullptr); Expects(!data.list.empty()); const auto messageLinkWrapper = [&](int messageId, QByteArray text) { return wrapMessageLink(messageId, text); }; auto oldIndex = (_messagesCount > 0) ? ((_messagesCount - 1) / kMessagesInFile) : 0; auto previous = _lastMessageInfo.get(); auto saved = std::optional(); auto block = QByteArray(); for (const auto &message : data.list) { if (Data::SkipMessageByDate(message, _settings)) { continue; } const auto newIndex = (_messagesCount / kMessagesInFile); if (oldIndex != newIndex) { if (const auto result = _chat->writeBlock(block); !result) { return result; } else if (const auto next = switchToNextChatFile(newIndex)) { Assert(saved.has_value() || _lastMessageInfo != nullptr); _lastMessageIdsPerFile.push_back(saved ? saved->id : _lastMessageInfo->id); block = QByteArray(); _lastMessageInfo = nullptr; previous = nullptr; saved = std::nullopt; oldIndex = newIndex; } else { return next; } } if (_chatFileEmpty) { if (const auto result = writeDialogOpening(oldIndex); !result) { return result; } _chatFileEmpty = false; } const auto date = message.date; if (DisplayDate(date, previous ? previous->date : 0)) { block.append(_chat->pushServiceMessage( --_dateMessageId, _dialog, _settings.path, FormatDateText(date))); } const auto &[info, content] = _chat->pushMessage( message, previous, _dialog, _settings.path, data.peers, _environment.internalLinksDomain, messageLinkWrapper); block.append(content); ++_messagesCount; saved = info; previous = &*saved; } if (saved) { _lastMessageInfo = std::make_unique(*saved); } return block.isEmpty() ? Result::Success() : _chat->writeBlock(block); } Result HtmlWriter::writeEmptySinglePeer() { Expects(_chat != nullptr); if (!_settings.onlySinglePeer() || _messagesCount != 0) { return Result::Success(); } Assert(_chatFileEmpty); if (const auto result = writeDialogOpening(0); !result) { return result; } return _chat->writeBlock(_chat->pushServiceMessage( --_dateMessageId, _dialog, _settings.path, "No exported messages")); } Result HtmlWriter::writeDialogEnd() { Expects(_settings.onlySinglePeer() || _chats != nullptr); Expects(_chat != nullptr); if (const auto result = writeEmptySinglePeer(); !result) { return result; } if (const auto closed = base::take(_chat)->close(); !closed) { return closed; } else if (_settings.onlySinglePeer()) { return Result::Success(); } using Type = Data::DialogInfo::Type; const auto TypeString = [](Type type) { switch (type) { case Type::Unknown: return "unknown"; case Type::Self: case Type::Replies: case Type::VerifyCodes: case Type::Personal: return "private"; case Type::Bot: return "bot"; case Type::PrivateGroup: case Type::PrivateSupergroup: case Type::PublicSupergroup: return "group"; case Type::PrivateChannel: case Type::PublicChannel: return "channel"; } Unexpected("Dialog type in TypeString."); }; const auto DeletedString = [](Type type) { switch (type) { case Type::Unknown: case Type::Self: case Type::Replies: case Type::VerifyCodes: case Type::Personal: case Type::Bot: return "Deleted Account"; case Type::PrivateGroup: case Type::PrivateSupergroup: case Type::PublicSupergroup: return "Deleted Group"; case Type::PrivateChannel: case Type::PublicChannel: return "Deleted Channel"; } Unexpected("Dialog type in TypeString."); }; const auto NameString = []( const Data::DialogInfo &dialog) -> QByteArray { if (dialog.type == Type::Self) { return "Saved messages"; } else if (dialog.type == Type::Replies) { return "Replies"; } else if (dialog.type == Type::VerifyCodes) { return "Verification Codes"; } return dialog.name; }; const auto LastNameString = []( const Data::DialogInfo &dialog) -> QByteArray { if (dialog.type != Type::Personal && dialog.type != Type::Bot) { return {}; } return dialog.lastName; }; const auto CountString = [](int count, bool outgoing) -> QByteArray { if (count == 1) { return outgoing ? "1 outgoing message" : "1 message"; } else if (!count) { return outgoing ? "No outgoing messages" : "No messages"; } return Data::NumberToString(count) + (outgoing ? " outgoing messages" : " messages"); }; auto userpic = UserpicData{ ((_dialog.type == Type::Self || _dialog.type == Type::Replies || _dialog.type == Type::VerifyCodes) ? kSavedMessagesColorIndex : Data::PeerColorIndex(_dialog.peerId)), kEntryUserpicSize }; userpic.firstName = NameString(_dialog); userpic.lastName = LastNameString(_dialog); const auto result = validateDialogsMode(_dialog.isLeftChannel); if (!result) { return result; } return _chats->writeBlock(_chats->pushListEntry( userpic, ComposeName(userpic, DeletedString(_dialog.type)), CountString(_messagesCount, _dialog.onlyMyMessages), TypeString(_dialog.type), (_messagesCount > 0 ? (_dialog.relativePath + "messages.html") : QString()))); } Result HtmlWriter::validateDialogsMode(bool isLeftChannel) { const auto mode = isLeftChannel ? DialogsMode::Left : DialogsMode::Chats; if (_dialogsMode == mode) { return Result::Success(); } else if (_dialogsMode != DialogsMode::None) { const auto result = _chats->writeBlock(_chats->popTag()); if (!result) { return result; } } _dialogsMode = mode; auto block = _chats->pushAbout(isLeftChannel ? _environment.aboutLeftChats : _environment.aboutChats); block.append(_chats->pushDiv("entry_list")); return _chats->writeBlock(block); } Result HtmlWriter::writeDialogsEnd() { if (_chats) { return base::take(_chats)->close(); } return Result::Success(); } Result HtmlWriter::writeDialogOpening(int index) { const auto name = (_dialog.name.isEmpty() && _dialog.lastName.isEmpty()) ? QByteArray("Deleted Account") : (_dialog.name + ' ' + _dialog.lastName); auto block = _chat->pushHeader( name, _settings.onlySinglePeer() ? QString() : _dialogsRelativePath); block.append(_chat->pushDiv("page_body chat_page")); block.append(_chat->pushDiv("history")); if (index > 0) { const auto previousPath = messagesFile(index - 1); block.append(_chat->pushTag("a", { { "class", "pagination block_link" }, { "href", previousPath.toUtf8() } })); block.append("Previous messages"); block.append(_chat->popTag()); } return _chat->writeBlock(block); } void HtmlWriter::pushSection( int priority, const QByteArray &label, const QByteArray &type, int count, const QString &path) { _savedSections.push_back({ priority, label, type, count, path }); } Result HtmlWriter::writeSections() { Expects(_summary != nullptr); if (_savedSections.empty()) { return Result::Success(); } else if (!_haveSections) { auto block = _summary->pushDiv( _summaryNeedDivider ? "sections with_divider" : "sections"); if (const auto result = _summary->writeBlock(block); !result) { return result; } _haveSections = true; _summaryNeedDivider = false; } auto block = QByteArray(); ranges::sort(_savedSections, std::less<>(), [](const SavedSection &data) { return data.priority; }); for (const auto §ion : base::take(_savedSections)) { block.append(_summary->pushSection( section.label, section.type, section.count, _summary->relativePath(section.path))); } return _summary->writeBlock(block); } QByteArray HtmlWriter::wrapMessageLink(int messageId, QByteArray text) { const auto it = ranges::find_if(_lastMessageIdsPerFile, [&](int maxMessageId) { return messageId <= maxMessageId; }); if (it == end(_lastMessageIdsPerFile)) { return "" + text + ""; } else { const auto index = it - begin(_lastMessageIdsPerFile); return "" + text + ""; } } Result HtmlWriter::switchToNextChatFile(int index) { Expects(_chat != nullptr); const auto nextPath = messagesFile(index); auto next = _chat->pushTag("a", { { "class", "pagination block_link" }, { "href", nextPath.toUtf8() } }); next.append("Next messages"); next.append(_chat->popTag()); if (const auto result = _chat->writeBlock(next); !result) { return result; } else if (const auto end = _chat->close(); !end) { return end; } _chat = fileWithRelativePath(_dialog.relativePath + nextPath); _chatFileEmpty = true; return Result::Success(); } Result HtmlWriter::finish() { Expects(_settings.onlySinglePeer() || _summary != nullptr); if (_settings.onlySinglePeer()) { return Result::Success(); } if (const auto result = writeSections(); !result) { return result; } auto block = QByteArray(); if (_haveSections) { block.append(_summary->popTag()); _summaryNeedDivider = true; _haveSections = false; } block.append(_summary->pushAbout( _environment.aboutTelegram, _summaryNeedDivider)); if (const auto result = _summary->writeBlock(block); !result) { return result; } return _summary->close(); } Result HtmlWriter::copyFile( const QString &source, const QString &relativePath) const { return File::Copy( source, pathWithRelativePath(relativePath), _stats); } QString HtmlWriter::mainFilePath() { return pathWithRelativePath(_settings.onlySinglePeer() ? messagesFile(0) : mainFileRelativePath()); } QString HtmlWriter::mainFileRelativePath() const { return "export_results.html"; } QString HtmlWriter::pathWithRelativePath(const QString &path) const { return _settings.path + path; } QString HtmlWriter::messagesFile(int index) const { return "messages" + (index > 0 ? QString::number(index + 1) : QString()) + ".html"; } std::unique_ptr HtmlWriter::fileWithRelativePath( const QString &path) const { return std::make_unique( pathWithRelativePath(path), _settings.path, _stats); } HtmlWriter::~HtmlWriter() = default; } // namespace Output } // namespace Export