diff --git a/Telegram/Resources/css/export_style.css b/Telegram/Resources/css/export_style.css new file mode 100644 index 00000000000000..e2e78152a1e4dc --- /dev/null +++ b/Telegram/Resources/css/export_style.css @@ -0,0 +1,3 @@ +.page_wrap { + background-color: #fff; +} diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 31043d9b4b79f7..005f8a19c3739b 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -1681,7 +1681,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_export_option_size_limit" = "Size limit: {size}"; "lng_export_header_format" = "Location and format"; "lng_export_option_location" = "Download path: {path}"; -"lng_export_option_text" = "Human-readable text"; +"lng_export_option_html" = "Human-readable HTML"; "lng_export_option_json" = "Machine-readable JSON"; "lng_export_start" = "Export"; "lng_export_state_initializing" = "Initializing..."; diff --git a/Telegram/Resources/qrc/telegram.qrc b/Telegram/Resources/qrc/telegram.qrc index e9ae8104395593..48b4398eb72420 100644 --- a/Telegram/Resources/qrc/telegram.qrc +++ b/Telegram/Resources/qrc/telegram.qrc @@ -1,4 +1,7 @@ + + ../css/export_style.css + ../fonts/OpenSans-Regular.ttf ../fonts/OpenSans-Bold.ttf diff --git a/Telegram/SourceFiles/export/data/export_data_types.cpp b/Telegram/SourceFiles/export/data/export_data_types.cpp index 9500010e2a65c5..a88841344fc60d 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.cpp +++ b/Telegram/SourceFiles/export/data/export_data_types.cpp @@ -458,6 +458,9 @@ User ParseUser(const MTPUser &data) { if (data.has_bot_info_version()) { result.isBot = true; } + if (data.is_self()) { + result.isSelf = true; + } const auto access_hash = data.has_access_hash() ? data.vaccess_hash : MTP_long(0); @@ -492,7 +495,8 @@ Chat ParseChat(const MTPChat &data) { result.input = MTP_inputPeerChat(MTP_int(result.id)); }, [&](const MTPDchannel &data) { result.id = data.vid.v; - result.broadcast = data.is_broadcast(); + result.isBroadcast = data.is_broadcast(); + result.isSupergroup = data.is_megagroup(); result.title = ParseString(data.vtitle); if (data.has_username()) { result.username = ParseString(data.vusername); @@ -502,7 +506,8 @@ Chat ParseChat(const MTPChat &data) { data.vaccess_hash); }, [&](const MTPDchannelForbidden &data) { result.id = data.vid.v; - result.broadcast = data.is_broadcast(); + result.isBroadcast = data.is_broadcast(); + result.isSupergroup = data.is_megagroup(); result.title = ParseString(data.vtitle); result.input = MTP_inputPeerChannel( MTP_int(result.id), @@ -1102,16 +1107,22 @@ SessionsList ParseWebSessionsList( DialogInfo::Type DialogTypeFromChat(const Chat &chat) { using Type = DialogInfo::Type; return chat.username.isEmpty() - ? (chat.broadcast + ? (chat.isBroadcast ? Type::PrivateChannel + : chat.isSupergroup + ? Type::PrivateSupergroup : Type::PrivateGroup) - : (chat.broadcast + : (chat.isBroadcast ? Type::PublicChannel - : Type::PublicGroup); + : Type::PublicSupergroup); } DialogInfo::Type DialogTypeFromUser(const User &user) { - return user.isBot ? DialogInfo::Type::Bot : DialogInfo::Type::Personal; + return user.isSelf + ? DialogInfo::Type::Self + : user.isBot + ? DialogInfo::Type::Bot + : DialogInfo::Type::Personal; } DialogsInfo ParseDialogsInfo(const MTPmessages_Dialogs &data) { @@ -1185,11 +1196,13 @@ void FinalizeDialogsInfo(DialogsInfo &info, const Settings &settings) { using Type = Settings::Type; const auto setting = [&] { switch (dialog.type) { + case DialogType::Self: case DialogType::Personal: return Type::PersonalChats; case DialogType::Bot: return Type::BotChats; - case DialogType::PrivateGroup: return Type::PrivateGroups; + case DialogType::PrivateGroup: + case DialogType::PrivateSupergroup: return Type::PrivateGroups; case DialogType::PrivateChannel: return Type::PrivateChannels; - case DialogType::PublicGroup: return Type::PublicGroups; + case DialogType::PublicSupergroup: return Type::PublicGroups; case DialogType::PublicChannel: return Type::PublicChannels; } Unexpected("Type in ApiWrap::onlyMyMessages."); diff --git a/Telegram/SourceFiles/export/data/export_data_types.h b/Telegram/SourceFiles/export/data/export_data_types.h index 9c60bbc1c895fc..5292954c9e08ba 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.h +++ b/Telegram/SourceFiles/export/data/export_data_types.h @@ -162,6 +162,7 @@ struct User { ContactInfo info; Utf8String username; bool isBot = false; + bool isSelf = false; MTPInputUser input = MTP_inputUserEmpty(); @@ -175,7 +176,8 @@ struct Chat { int32 id = 0; Utf8String title; Utf8String username; - bool broadcast = false; + bool isBroadcast = false; + bool isSupergroup = false; MTPInputPeer input = MTP_inputPeerEmpty(); }; @@ -466,10 +468,12 @@ std::map ParseMessagesList( struct DialogInfo { enum class Type { Unknown, + Self, Personal, Bot, PrivateGroup, - PublicGroup, + PrivateSupergroup, + PublicSupergroup, PrivateChannel, PublicChannel, }; @@ -479,7 +483,7 @@ struct DialogInfo { MTPInputPeer input = MTP_inputPeerEmpty(); int32 topMessageId = 0; TimeId topMessageDate = 0; - PeerId peerId = 0; + PeerId peerId; // User messages splits which contained that dialog. std::vector splits; diff --git a/Telegram/SourceFiles/export/export_api_wrap.cpp b/Telegram/SourceFiles/export/export_api_wrap.cpp index 8e64aeaea8edc8..c7e5c404abe58a 100644 --- a/Telegram/SourceFiles/export/export_api_wrap.cpp +++ b/Telegram/SourceFiles/export/export_api_wrap.cpp @@ -69,13 +69,15 @@ LocationKey ComputeLocationKey(const Data::FileLocation &value) { Settings::Type SettingsFromDialogsType(Data::DialogInfo::Type type) { using DialogType = Data::DialogInfo::Type; switch (type) { + case DialogType::Self: case DialogType::Personal: return Settings::Type::PersonalChats; case DialogType::Bot: return Settings::Type::BotChats; case DialogType::PrivateGroup: + case DialogType::PrivateSupergroup: return Settings::Type::PrivateGroups; - case DialogType::PublicGroup: + case DialogType::PublicSupergroup: return Settings::Type::PublicGroups; case DialogType::PrivateChannel: return Settings::Type::PrivateChannels; diff --git a/Telegram/SourceFiles/export/export_controller.cpp b/Telegram/SourceFiles/export/export_controller.cpp index e8a8f04aaf4e5e..9203bf20719505 100644 --- a/Telegram/SourceFiles/export/export_controller.cpp +++ b/Telegram/SourceFiles/export/export_controller.cpp @@ -223,43 +223,12 @@ void Controller::startExport(const Settings &settings) { } _settings = base::duplicate(settings); - if (!normalizePath()) { - ioError(_settings.path); - return; - } + _settings.path = Output::NormalizePath(_settings.path); _writer = Output::CreateWriter(_settings.format); fillExportSteps(); exportNext(); } -bool Controller::normalizePath() { - QDir folder(_settings.path); - const auto path = folder.absolutePath(); - _settings.path = path.endsWith('/') ? path : (path + '/'); - if (!folder.exists()) { - return true; - } - const auto mode = QDir::AllEntries | QDir::NoDotAndDotDot; - const auto list = folder.entryInfoList(mode); - if (list.isEmpty()) { - return true; - } - const auto date = QDate::currentDate(); - const auto base = QString("DataExport_%1_%2_%3" - ).arg(date.day(), 2, 10, QChar('0') - ).arg(date.month(), 2, 10, QChar('0') - ).arg(date.year()); - const auto add = [&](int i) { - return base + (i ? " (" + QString::number(i) + ')' : QString()); - }; - auto index = 0; - while (QDir(_settings.path + add(index)).exists()) { - ++index; - } - _settings.path += add(index) + '/'; - return true; -} - void Controller::fillExportSteps() { using Type = Settings::Type; _steps.push_back(Step::Initializing); diff --git a/Telegram/SourceFiles/export/output/export_output_abstract.cpp b/Telegram/SourceFiles/export/output/export_output_abstract.cpp index cad49a6cd8a423..684850d14c41c3 100644 --- a/Telegram/SourceFiles/export/output/export_output_abstract.cpp +++ b/Telegram/SourceFiles/export/output/export_output_abstract.cpp @@ -8,18 +8,476 @@ For license and copyright information please follow this link: #include "export/output/export_output_abstract.h" #include "export/output/export_output_text.h" +#include "export/output/export_output_html.h" #include "export/output/export_output_json.h" +#include "export/output/export_output_stats.h" +#include "export/output/export_output_result.h" + +#include +#include namespace Export { namespace Output { +QString NormalizePath(const QString &source) { + QDir folder(source); + const auto path = folder.absolutePath(); + auto result = path.endsWith('/') ? path : (path + '/'); + if (!folder.exists()) { + return result; + } + const auto mode = QDir::AllEntries | QDir::NoDotAndDotDot; + const auto list = folder.entryInfoList(mode); + if (list.isEmpty()) { + return result; + } + const auto date = QDate::currentDate(); + const auto base = QString("DataExport_%1_%2_%3" + ).arg(date.day(), 2, 10, QChar('0') + ).arg(date.month(), 2, 10, QChar('0') + ).arg(date.year()); + const auto add = [&](int i) { + return base + (i ? " (" + QString::number(i) + ')' : QString()); + }; + auto index = 0; + while (QDir(result + add(index)).exists()) { + ++index; + } + result += add(index) + '/'; + return result; +} + std::unique_ptr CreateWriter(Format format) { switch (format) { + case Format::Html: return std::make_unique(); case Format::Text: return std::make_unique(); case Format::Json: return std::make_unique(); } Unexpected("Format in Export::Output::CreateWriter."); } +Stats AbstractWriter::produceTestExample(const QString &path) { + auto result = Stats(); + const auto folder = QDir(path).absolutePath(); + auto settings = Settings(); + settings.format = format(); + settings.path = (folder.endsWith('/') ? folder : (folder + '/')) + + "ExportExample/"; + settings.internalLinksDomain = "https://t.me/"; + settings.types = Settings::Type::AllMask; + settings.fullChats = Settings::Type::AllMask + & ~(Settings::Type::PublicChannels | Settings::Type::PublicGroups); + settings.media.types = MediaSettings::Type::AllMask; + settings.media.sizeLimit = 1024 * 1024; + + const auto check = [](Result result) { + Assert(result.isSuccess()); + }; + + check(start(settings, &result)); + + const auto counter = [&] { + static auto GlobalCounter = 0; + return ++GlobalCounter; + }; + const auto date = [&] { + return time(nullptr) - 86400 + counter(); + }; + const auto prevdate = [&] { + return date() - 86400; + }; + + auto personal = Data::PersonalInfo(); + personal.bio = "Nice text about me."; + personal.user.info.firstName = "John"; + personal.user.info.lastName = "Preston"; + personal.user.info.phoneNumber = "447400000000"; + personal.user.info.date = date(); + personal.user.username = "preston"; + personal.user.info.userId = counter(); + personal.user.isBot = false; + personal.user.isSelf = true; + check(writePersonal(personal)); + + const auto generatePhoto = [&] { + static auto index = 0; + auto result = Data::Photo(); + result.date = date(); + result.id = counter(); + result.image.width = 512; + result.image.height = 512; + result.image.file.relativePath = "Files/Photo_" + + QString::number(++index) + + ".jpg"; + return result; + }; + + auto userpics = Data::UserpicsInfo(); + userpics.count = 3; + auto userpicsSlice1 = Data::UserpicsSlice(); + userpicsSlice1.list.push_back(generatePhoto()); + userpicsSlice1.list.push_back(generatePhoto()); + auto userpicsSlice2 = Data::UserpicsSlice(); + userpicsSlice2.list.push_back(generatePhoto()); + check(writeUserpicsStart(userpics)); + check(writeUserpicsSlice(userpicsSlice1)); + check(writeUserpicsSlice(userpicsSlice2)); + check(writeUserpicsEnd()); + + auto contacts = Data::ContactsList(); + auto topUser = Data::TopPeer(); + auto user = personal.user; + auto peerUser = Data::Peer{ user }; + topUser.peer = peerUser; + topUser.rating = 0.5; + auto topChat = Data::TopPeer(); + auto chat = Data::Chat(); + chat.id = counter(); + chat.title = "Group chat"; + auto peerChat = Data::Peer{ chat }; + topChat.peer = peerChat; + topChat.rating = 0.25; + auto topBot = Data::TopPeer(); + auto bot = Data::User(); + bot.info.date = date(); + bot.isBot = true; + bot.info.firstName = "Bot"; + bot.info.lastName = "Father"; + bot.info.userId = counter(); + bot.username = "botfather"; + auto peerBot = Data::Peer{ bot }; + topBot.peer = peerBot; + topBot.rating = 0.125; + + auto peers = std::map(); + peers.emplace(peerUser.id(), peerUser); + peers.emplace(peerBot.id(), peerBot); + peers.emplace(peerChat.id(), peerChat); + + contacts.correspondents.push_back(topUser); + contacts.correspondents.push_back(topChat); + contacts.inlineBots.push_back(topBot); + contacts.inlineBots.push_back(topBot); + contacts.phoneCalls.push_back(topUser); + contacts.list.push_back(user.info); + contacts.list.push_back(bot.info); + + check(writeContactsList(contacts)); + + auto sessions = Data::SessionsList(); + auto session = Data::Session(); + session.applicationName = "Telegram Desktop"; + session.applicationVersion = "1.3.8"; + session.country = "GB"; + session.created = date(); + session.deviceModel = "PC"; + session.ip = "127.0.0.1"; + session.lastActive = date(); + session.platform = "Windows"; + session.region = "London"; + session.systemVersion = "10"; + sessions.list.push_back(session); + sessions.list.push_back(session); + auto webSession = Data::WebSession(); + webSession.botUsername = "botfather"; + webSession.browser = "Google Chrome"; + webSession.created = date(); + webSession.domain = "telegram.org"; + webSession.ip = "127.0.0.1"; + webSession.lastActive = date(); + webSession.platform = "Windows"; + webSession.region = "London, GB"; + sessions.webList.push_back(webSession); + sessions.webList.push_back(webSession); + check(writeSessionsList(sessions)); + + auto sampleMessage = [&] { + auto message = Data::Message(); + message.id = counter(); + message.date = prevdate(); + message.edited = date(); + message.forwardedFromId = user.info.userId; + message.fromId = user.info.userId; + message.replyToMsgId = counter(); + message.viaBotId = bot.info.userId; + message.text.push_back(Data::TextPart{ + Data::TextPart::Type::Text, + ("Text message " + QString::number(counter())).toUtf8() + }); + return message; + }; + auto sliceBot1 = Data::MessagesSlice(); + sliceBot1.peers = peers; + sliceBot1.list.push_back(sampleMessage()); + sliceBot1.list.push_back([&] { + auto message = sampleMessage(); + message.media.content = generatePhoto(); + message.media.ttl = counter(); + return message; + }()); + sliceBot1.list.push_back([&] { + auto message = sampleMessage(); + auto document = Data::Document(); + document.date = prevdate(); + document.duration = counter(); + auto photo = generatePhoto(); + document.file = photo.image.file; + document.width = photo.image.width; + document.height = photo.image.height; + document.id = counter(); + message.media.content = document; + return message; + }()); + sliceBot1.list.push_back([&] { + auto message = sampleMessage(); + message.media.content = user.info; + return message; + }()); + auto sliceBot2 = Data::MessagesSlice(); + sliceBot2.peers = peers; + sliceBot2.list.push_back([&] { + auto message = sampleMessage(); + auto point = Data::GeoPoint(); + point.latitude = 1.5; + point.longitude = 2.8; + point.valid = true; + message.media.content = point; + message.media.ttl = counter(); + return message; + }()); + sliceBot2.list.push_back([&] { + auto message = sampleMessage(); + message.replyToMsgId = sliceBot1.list.back().id; + auto venue = Data::Venue(); + venue.point.latitude = 1.5; + venue.point.longitude = 2.8; + venue.point.valid = true; + venue.address = "Test address"; + venue.title = "Test venue"; + message.media.content = venue; + return message; + }()); + sliceBot2.list.push_back([&] { + auto message = sampleMessage(); + auto game = Data::Game(); + game.botId = bot.info.userId; + game.title = "Test game"; + game.description = "Test game description"; + game.id = counter(); + game.shortName = "testgame"; + message.media.content = game; + return message; + }()); + sliceBot2.list.push_back([&] { + auto message = sampleMessage(); + auto invoice = Data::Invoice(); + invoice.amount = counter(); + invoice.currency = "GBP"; + invoice.title = "Huge invoice."; + invoice.description = "So money."; + invoice.receiptMsgId = sliceBot2.list.front().id; + message.media.content = invoice; + return message; + }()); + auto serviceMessage = [&] { + auto message = Data::Message(); + message.id = counter(); + message.date = prevdate(); + message.fromId = user.info.userId; + return message; + }; + auto sliceChat1 = Data::MessagesSlice(); + sliceChat1.peers = peers; + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatCreate(); + action.title = "Test chat"; + action.userIds.push_back(user.info.userId); + action.userIds.push_back(bot.info.userId); + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatEditTitle(); + action.title = "New title"; + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatEditPhoto(); + action.photo = generatePhoto(); + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatDeletePhoto(); + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatAddUser(); + action.userIds.push_back(user.info.userId); + action.userIds.push_back(bot.info.userId); + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatDeleteUser(); + action.userId = bot.info.userId; + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatJoinedByLink(); + action.inviterId = bot.info.userId; + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChannelCreate(); + action.title = "Channel name"; + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChatMigrateTo(); + action.channelId = chat.id; + message.action.content = action; + return message; + }()); + sliceChat1.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionChannelMigrateFrom(); + action.chatId = chat.id; + action.title = "Supergroup now"; + message.action.content = action; + return message; + }()); + auto sliceChat2 = Data::MessagesSlice(); + sliceChat2.peers = peers; + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionPinMessage(); + message.replyToMsgId = sliceChat1.list.back().id; + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionHistoryClear(); + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionGameScore(); + action.score = counter(); + action.gameId = counter(); + message.replyToMsgId = sliceChat2.list.back().id; + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionPaymentSent(); + action.amount = counter(); + action.currency = "GBP"; + message.replyToMsgId = sliceChat2.list.front().id; + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionPhoneCall(); + action.duration = counter(); + action.discardReason = Data::ActionPhoneCall::DiscardReason::Busy; + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionScreenshotTaken(); + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionCustomAction(); + action.message = "Custom chat action."; + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionBotAllowed(); + action.domain = "telegram.org"; + message.action.content = action; + return message; + }()); + sliceChat2.list.push_back([&] { + auto message = serviceMessage(); + auto action = Data::ActionSecureValuesSent(); + using Type = Data::ActionSecureValuesSent::Type; + action.types.push_back(Type::BankStatement); + action.types.push_back(Type::Phone); + message.action.content = action; + return message; + }()); + auto dialogs = Data::DialogsInfo(); + auto dialogBot = Data::DialogInfo(); + dialogBot.messagesCountPerSplit.push_back(sliceBot1.list.size()); + dialogBot.messagesCountPerSplit.push_back(sliceBot2.list.size()); + dialogBot.type = Data::DialogInfo::Type::Bot; + dialogBot.name = peerBot.name(); + dialogBot.onlyMyMessages = false; + dialogBot.peerId = peerBot.id(); + dialogBot.relativePath = "Chats/C_" + QString::number(counter()) + '/'; + dialogBot.splits.push_back(0); + dialogBot.splits.push_back(1); + dialogBot.topMessageDate = sliceBot2.list.back().date; + dialogBot.topMessageId = sliceBot2.list.back().id; + auto dialogChat = Data::DialogInfo(); + dialogChat.messagesCountPerSplit.push_back(sliceChat1.list.size()); + dialogChat.messagesCountPerSplit.push_back(sliceChat2.list.size()); + dialogChat.type = Data::DialogInfo::Type::PrivateGroup; + dialogChat.name = peerChat.name(); + dialogChat.onlyMyMessages = true; + dialogChat.peerId = peerChat.id(); + dialogChat.relativePath = "Chats/C_" + QString::number(counter()) + '/'; + dialogChat.splits.push_back(0); + dialogChat.splits.push_back(1); + dialogChat.topMessageDate = sliceChat2.list.back().date; + dialogChat.topMessageId = sliceChat2.list.back().id; + dialogs.list.push_back(dialogBot); + dialogs.list.push_back(dialogChat); + + check(writeDialogsStart(dialogs)); + check(writeDialogStart(dialogBot)); + check(writeDialogSlice(sliceBot1)); + check(writeDialogSlice(sliceBot2)); + check(writeDialogEnd()); + check(writeDialogStart(dialogChat)); + check(writeDialogSlice(sliceChat1)); + check(writeDialogSlice(sliceChat2)); + check(writeDialogEnd()); + check(writeDialogsEnd()); + + check(writeLeftChannelsStart(Data::DialogsInfo())); + check(writeLeftChannelsEnd()); + + check(finish()); + + return result; +} + + } // namespace Output } // namespace Export diff --git a/Telegram/SourceFiles/export/output/export_output_abstract.h b/Telegram/SourceFiles/export/output/export_output_abstract.h index 7d79658dc1b0bd..b0a485ca9d9862 100644 --- a/Telegram/SourceFiles/export/output/export_output_abstract.h +++ b/Telegram/SourceFiles/export/output/export_output_abstract.h @@ -25,18 +25,22 @@ struct Settings; namespace Output { +QString NormalizePath(const QString &source); + struct Result; class Stats; enum class Format { - Text, + Html, Json, + Text, Yaml, - Html, }; class AbstractWriter { public: + [[nodiscard]] virtual Format format() = 0; + [[nodiscard]] virtual Result start( const Settings &settings, Stats *stats) = 0; @@ -80,6 +84,8 @@ class AbstractWriter { virtual ~AbstractWriter() = default; + Stats produceTestExample(const QString &path); + }; std::unique_ptr CreateWriter(Format format); diff --git a/Telegram/SourceFiles/export/output/export_output_file.cpp b/Telegram/SourceFiles/export/output/export_output_file.cpp index 77b76706f3f6fe..1d56ce4713681a 100644 --- a/Telegram/SourceFiles/export/output/export_output_file.cpp +++ b/Telegram/SourceFiles/export/output/export_output_file.cpp @@ -120,5 +120,20 @@ QString File::PrepareRelativePath( } } +Result File::Copy( + const QString &source, + const QString &path, + Stats *stats) { + QFile f(source); + if (!f.exists() || !f.open(QIODevice::ReadOnly)) { + return Result(Result::Type::FatalError, source); + } + const auto bytes = f.readAll(); + if (bytes.size() != f.size()) { + return Result(Result::Type::FatalError, source); + } + return File(path, stats).writeBlock(bytes); +} + } // namespace Output } // namespace File diff --git a/Telegram/SourceFiles/export/output/export_output_file.h b/Telegram/SourceFiles/export/output/export_output_file.h index d72e4420faff5a..33bf5bf4b5cfbc 100644 --- a/Telegram/SourceFiles/export/output/export_output_file.h +++ b/Telegram/SourceFiles/export/output/export_output_file.h @@ -32,6 +32,11 @@ class File { const QString &folder, const QString &suggested); + [[nodiscard]] static Result Copy( + const QString &source, + const QString &path, + Stats *stats); + private: [[nodiscard]] Result reopen(); [[nodiscard]] Result writeBlockAttempt(const QByteArray &block); diff --git a/Telegram/SourceFiles/export/output/export_output_html.cpp b/Telegram/SourceFiles/export/output/export_output_html.cpp new file mode 100644 index 00000000000000..51ac86ab8b17da --- /dev/null +++ b/Telegram/SourceFiles/export/output/export_output_html.cpp @@ -0,0 +1,1217 @@ +/* +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 "export/output/export_output_result.h" +#include "export/data/export_data_types.h" +#include "export/data/export_data_about.h" +#include "core/utils.h" + +#include + +namespace Export { +namespace Output { +namespace { + +const auto kLineBreak = QByteArrayLiteral("
"); + +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; +} + +void SerializeMultiline( + QByteArray &appendTo, + const QByteArray &value, + int newline) { + const auto data = value.data(); + auto offset = 0; + do { + appendTo.append("> "); + const auto win = (newline > 0 && *(data + newline - 1) == '\r'); + if (win) --newline; + appendTo.append(data + offset, newline - offset).append(kLineBreak); + if (win) ++newline; + offset = newline + 1; + newline = value.indexOf('\n', offset); + } while (newline > 0); + if (const auto size = value.size(); size > offset) { + appendTo.append("> "); + appendTo.append(data + offset, size - offset).append(kLineBreak); + } +} + +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 SerializeKeyValue( + std::vector> &&values) { + auto result = QByteArray(); + for (const auto &[key, value] : values) { + if (value.isEmpty()) { + continue; + } + result.append(key); + if (const auto newline = value.indexOf('\n'); newline >= 0) { + result.append(':').append(kLineBreak); + SerializeMultiline(result, value, newline); + } else { + result.append(": ").append(value).append(kLineBreak); + } + } + return result; +} + +Data::Utf8String FormatUsername(const Data::Utf8String &username) { + return username.isEmpty() ? username : ('@' + username); +} + +QByteArray FormatFilePath(const Data::File &file) { + return file.relativePath.toUtf8(); +} + +QByteArray SerializeLink( + const Data::Utf8String &text, + const QString &path) { + return "" + text + ""; +} + +QByteArray SerializeMessage( + Fn relativePath, + const Data::Message &message, + const std::map &peers, + const QString &internalLinksDomain) { + using namespace Data; + + if (message.media.content.is()) { + return SerializeString("Error! This message is not supported " + "by this version of Telegram Desktop. " + "Please update the application."); + } + + const auto peer = [&](PeerId peerId) -> const Peer& { + if (const auto i = peers.find(peerId); i != end(peers)) { + return i->second; + } + static auto empty = Peer{ User() }; + return empty; + }; + const auto user = [&](int32 userId) -> const User& { + if (const auto result = peer(UserPeerId(userId)).user()) { + return *result; + } + static auto empty = User(); + return empty; + }; + const auto chat = [&](int32 chatId) -> const Chat& { + if (const auto result = peer(ChatPeerId(chatId)).chat()) { + return *result; + } + static auto empty = Chat(); + return empty; + }; + + auto values = std::vector>{ + { "ID", SerializeString(NumberToString(message.id)) }, + { "Date", SerializeString(FormatDateTime(message.date)) }, + { "Edited", SerializeString(FormatDateTime(message.edited)) }, + }; + const auto pushBare = [&]( + const QByteArray &key, + const QByteArray &value) { + values.emplace_back(key, value); + }; + const auto push = [&](const QByteArray &key, const QByteArray &value) { + if (!value.isEmpty()) { + pushBare(key, SerializeString(value)); + } + }; + const auto wrapPeerName = [&](PeerId peerId) { + const auto result = peer(peerId).name(); + return result.isEmpty() ? QByteArray("(deleted peer)") : result; + }; + const auto wrapUserName = [&](int32 userId) { + const auto result = user(userId).name(); + return result.isEmpty() ? QByteArray("(deleted user)") : result; + }; + const auto pushFrom = [&](const QByteArray &label = "From") { + if (message.fromId) { + push(label, wrapUserName(message.fromId)); + } + }; + const auto pushReplyToMsgId = [&]( + const QByteArray &label = "Reply to message") { + if (message.replyToMsgId) { + push(label, "ID-" + NumberToString(message.replyToMsgId)); + } + }; + const auto pushUserNames = [&]( + const std::vector &data, + const QByteArray &labelOne = "Member", + const QByteArray &labelMany = "Members") { + auto list = std::vector(); + for (const auto userId : data) { + list.push_back(SerializeString(wrapUserName(userId))); + } + if (list.size() == 1) { + pushBare(labelOne, list[0]); + } else if (!list.empty()) { + pushBare(labelMany, JoinList(", ", list)); + } + }; + const auto pushActor = [&] { + pushFrom("Actor"); + }; + const auto pushAction = [&](const QByteArray &action) { + push("Action", action); + }; + const auto pushTTL = [&]( + const QByteArray &label = "Self destruct period") { + if (const auto ttl = message.media.ttl) { + push(label, NumberToString(ttl) + " sec."); + } + }; + + using SkipReason = Data::File::SkipReason; + const auto pushPath = [&]( + const Data::File &file, + const QByteArray &label, + const QByteArray &name = QByteArray()) { + Expects(!file.relativePath.isEmpty() + || file.skipReason != SkipReason::None); + + pushBare(label, [&]() -> QByteArray { + const auto pre = name.isEmpty() + ? QByteArray() + : SerializeString(name + ' '); + switch (file.skipReason) { + case SkipReason::Unavailable: return pre + "(file unavailable)"; + case SkipReason::FileSize: return pre + "(file too large)"; + case SkipReason::FileType: return pre + "(file skipped)"; + case SkipReason::None: return SerializeLink( + FormatFilePath(file), + relativePath(file.relativePath)); + } + Unexpected("Skip reason while writing file path."); + }()); + }; + const auto pushPhoto = [&](const Image &image) { + pushPath(image.file, "Photo"); + if (image.width && image.height) { + push("Width", NumberToString(image.width)); + push("Height", NumberToString(image.height)); + } + }; + + message.action.content.match([&](const ActionChatCreate &data) { + pushActor(); + pushAction("Create group"); + push("Title", data.title); + pushUserNames(data.userIds); + }, [&](const ActionChatEditTitle &data) { + pushActor(); + pushAction("Edit group title"); + push("New title", data.title); + }, [&](const ActionChatEditPhoto &data) { + pushActor(); + pushAction("Edit group photo"); + pushPhoto(data.photo.image); + }, [&](const ActionChatDeletePhoto &data) { + pushActor(); + pushAction("Delete group photo"); + }, [&](const ActionChatAddUser &data) { + pushActor(); + pushAction("Invite members"); + pushUserNames(data.userIds); + }, [&](const ActionChatDeleteUser &data) { + pushActor(); + pushAction("Remove members"); + push("Member", wrapUserName(data.userId)); + }, [&](const ActionChatJoinedByLink &data) { + pushActor(); + pushAction("Join group by link"); + push("Inviter", wrapUserName(data.inviterId)); + }, [&](const ActionChannelCreate &data) { + pushActor(); + pushAction("Create channel"); + push("Title", data.title); + }, [&](const ActionChatMigrateTo &data) { + pushActor(); + pushAction("Migrate this group to supergroup"); + }, [&](const ActionChannelMigrateFrom &data) { + pushActor(); + pushAction("Migrate this supergroup from group"); + push("Title", data.title); + }, [&](const ActionPinMessage &data) { + pushActor(); + pushAction("Pin message"); + pushReplyToMsgId("Message"); + }, [&](const ActionHistoryClear &data) { + pushActor(); + pushAction("Clear history"); + }, [&](const ActionGameScore &data) { + pushActor(); + pushAction("Score in a game"); + pushReplyToMsgId("Game message"); + push("Score", NumberToString(data.score)); + }, [&](const ActionPaymentSent &data) { + pushAction("Send payment"); + push( + "Amount", + Data::FormatMoneyAmount(data.amount, data.currency)); + pushReplyToMsgId("Invoice message"); + }, [&](const ActionPhoneCall &data) { + pushActor(); + pushAction("Phone call"); + if (data.duration) { + push("Duration", NumberToString(data.duration) + " sec."); + } + using Reason = ActionPhoneCall::DiscardReason; + push("Discard reason", [&] { + switch (data.discardReason) { + case Reason::Busy: return "Busy"; + case Reason::Disconnect: return "Disconnect"; + case Reason::Hangup: return "Hangup"; + case Reason::Missed: return "Missed"; + } + return ""; + }()); + }, [&](const ActionScreenshotTaken &data) { + pushActor(); + pushAction("Take screenshot"); + }, [&](const ActionCustomAction &data) { + pushActor(); + push("Information", data.message); + }, [&](const ActionBotAllowed &data) { + pushAction("Allow sending messages"); + push("Reason", "Login on \"" + data.domain + "\""); + }, [&](const ActionSecureValuesSent &data) { + pushAction("Send Telegram Passport values"); + 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 ""; + }()); + } + if (list.size() == 1) { + push("Value", list[0]); + } else if (!list.empty()) { + push("Values", JoinList(", ", list)); + } + }, [](const base::none_type &) {}); + + if (!message.action.content) { + pushFrom(); + push("Author", message.signature); + if (message.forwardedFromId) { + push("Forwarded from", wrapPeerName(message.forwardedFromId)); + } + pushReplyToMsgId(); + if (message.viaBotId) { + push("Via", user(message.viaBotId).username); + } + } + + message.media.content.match([&](const Photo &photo) { + pushPhoto(photo.image); + pushTTL(); + }, [&](const Document &data) { + const auto pushMyPath = [&](const QByteArray &label) { + return pushPath(data.file, label); + }; + if (data.isSticker) { + pushMyPath("Sticker"); + push("Emoji", data.stickerEmoji); + } else if (data.isVideoMessage) { + pushMyPath("Video message"); + } else if (data.isVoiceMessage) { + pushMyPath("Voice message"); + } else if (data.isAnimated) { + pushMyPath("Animation"); + } else if (data.isVideoFile) { + pushMyPath("Video file"); + } else if (data.isAudioFile) { + pushMyPath("Audio file"); + push("Performer", data.songPerformer); + push("Title", data.songTitle); + } else { + pushMyPath("File"); + } + if (!data.isSticker) { + push("Mime type", data.mime); + } + if (data.duration) { + push("Duration", NumberToString(data.duration) + " sec."); + } + if (data.width && data.height) { + push("Width", NumberToString(data.width)); + push("Height", NumberToString(data.height)); + } + pushTTL(); + }, [&](const ContactInfo &data) { + pushBare("Contact information", SerializeKeyValue({ + { "First name", data.firstName }, + { "Last name", data.lastName }, + { "Phone number", FormatPhoneNumber(data.phoneNumber) }, + })); + }, [&](const GeoPoint &data) { + pushBare("Location", data.valid ? SerializeKeyValue({ + { "Latitude", NumberToString(data.latitude) }, + { "Longitude", NumberToString(data.longitude) }, + }) : QByteArray("(empty value)")); + pushTTL("Live location period"); + }, [&](const Venue &data) { + push("Place name", data.title); + push("Address", data.address); + if (data.point.valid) { + pushBare("Location", SerializeKeyValue({ + { "Latitude", NumberToString(data.point.latitude) }, + { "Longitude", NumberToString(data.point.longitude) }, + })); + } + }, [&](const Game &data) { + push("Game", data.title); + push("Description", data.description); + if (data.botId != 0 && !data.shortName.isEmpty()) { + const auto bot = user(data.botId); + if (bot.isBot && !bot.username.isEmpty()) { + push("Link", internalLinksDomain.toUtf8() + + bot.username + + "?game=" + + data.shortName); + } + } + }, [&](const Invoice &data) { + pushBare("Invoice", SerializeKeyValue({ + { "Title", data.title }, + { "Description", data.description }, + { + "Amount", + Data::FormatMoneyAmount(data.amount, data.currency) + }, + { "Receipt message", (data.receiptMsgId + ? "ID-" + NumberToString(data.receiptMsgId) + : QByteArray()) } + })); + }, [](const UnsupportedMedia &data) { + Unexpected("Unsupported message."); + }, [](const base::none_type &) {}); + + auto value = JoinList(QByteArray(), ranges::view::all( + message.text + ) | ranges::view::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 + ""; + } + Unexpected("Type in text entities serialization."); + }) | ranges::to_vector); + pushBare("Text", value); + + return SerializeKeyValue(std::move(values)); +} + +} // namespace + +class HtmlWriter::Wrap { +public: + Wrap(const QString &path, const QString &base, Stats *stats); + + [[nodiscard]] bool empty() const; + + [[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: + QByteArray begin() const; + QByteArray end() const; + + File _file; + bool _closed = false; + QByteArray _base; + +}; + +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(); +} + +bool HtmlWriter::Wrap::empty() const { + return _file.empty(); +} + +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(begin() + block); + } + return _file.writeBlock(block); + }(); + if (!result) { + _closed = true; + } + return result; +} + +Result HtmlWriter::Wrap::close() { + if (!std::exchange(_closed, true) && !_file.empty()) { + return _file.writeBlock(end()); + } + 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::begin() const { + return "\ +\n\ +\n\ +\n\ + \n\ + Exported Data\n\ + \n\ + \n\ +\n\ +\n\ +
\n"; +} + +QByteArray HtmlWriter::Wrap::end() const { + return "\ +
\n\ +\n\ +\n"; +} + +HtmlWriter::Wrap::~Wrap() { + Expects(_file.empty() || _closed); +} + +HtmlWriter::HtmlWriter() = default; + +Result HtmlWriter::start(const Settings &settings, Stats *stats) { + Expects(settings.path.endsWith('/')); + + _settings = base::duplicate(settings); + _stats = stats; + _summary = fileWithRelativePath(mainFileRelativePath()); + + //const auto result = copyFile( + // ":/export/css/bootstrap.min.css", + // "css/bootstrap.min.css"); + //if (!result) { + // return result; + //} + return copyFile(":/export/css/style.css", "css/style.css"); +} + +Result HtmlWriter::writePersonal(const Data::PersonalInfo &data) { + Expects(_summary != nullptr); + + const auto &info = data.user.info; + const auto serialized = SerializeKeyValue({ + { "First name", SerializeString(info.firstName) }, + { "Last name", SerializeString(info.lastName) }, + { + "Phone number", + SerializeString(Data::FormatPhoneNumber(info.phoneNumber)) + }, + { "Username", SerializeString(FormatUsername(data.user.username)) }, + { "Bio", SerializeString(data.bio) }, + }) + + kLineBreak + + SerializeString(Data::AboutPersonalInfo()) + + kLineBreak + + kLineBreak; + return _summary->writeBlock(serialized); +} + +Result HtmlWriter::writeUserpicsStart(const Data::UserpicsInfo &data) { + Expects(_summary != nullptr); + Expects(_userpics == nullptr); + + _userpicsCount = data.count; + if (!_userpicsCount) { + return Result::Success(); + } + const auto filename = "personal_photos.html"; + _userpics = fileWithRelativePath(filename); + + const auto serialized = SerializeLink( + "Personal photos " + "(" + Data::NumberToString(_userpicsCount) + ")", + _summary->relativePath(filename)) + + kLineBreak + + kLineBreak; + return _summary->writeBlock(serialized); +} + +Result HtmlWriter::writeUserpicsSlice(const Data::UserpicsSlice &data) { + Expects(_userpics != nullptr); + Expects(!data.list.empty()); + + auto lines = std::vector(); + lines.reserve(data.list.size()); + for (const auto &userpic : data.list) { + if (!userpic.date) { + lines.push_back("(deleted photo)"); + } else { + using SkipReason = Data::File::SkipReason; + const auto &file = userpic.image.file; + Assert(!file.relativePath.isEmpty() + || file.skipReason != SkipReason::None); + const auto path = [&]() -> Data::Utf8String { + switch (file.skipReason) { + case SkipReason::Unavailable: return "(file unavailable)"; + case SkipReason::FileSize: return "(file too large)"; + case SkipReason::FileType: return "(file skipped)"; + case SkipReason::None: return SerializeLink( + FormatFilePath(file), + _userpics->relativePath(file.relativePath)); + } + Unexpected("Skip reason while writing photo path."); + }(); + lines.push_back(SerializeKeyValue({ + { + "Date", + SerializeString(Data::FormatDateTime(userpic.date)) + }, + { "Photo", path }, + })); + } + } + return _userpics->writeBlock(JoinList(kLineBreak, lines) + kLineBreak); +} + +Result HtmlWriter::writeUserpicsEnd() { + if (_userpics) { + return base::take(_userpics)->close(); + } + return Result::Success(); +} + +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 = "contacts.html"; + const auto file = fileWithRelativePath(filename); + auto list = std::vector(); + list.reserve(data.list.size()); + for (const auto index : Data::SortedContactsIndices(data)) { + const auto &contact = data.list[index]; + if (contact.firstName.isEmpty() + && contact.lastName.isEmpty() + && contact.phoneNumber.isEmpty()) { + list.push_back("(deleted user)" + kLineBreak); + } else { + list.push_back(SerializeKeyValue({ + { "First name", SerializeString(contact.firstName) }, + { "Last name", SerializeString(contact.lastName) }, + { + "Phone number", + SerializeString( + Data::FormatPhoneNumber(contact.phoneNumber)) + }, + { + "Date", + SerializeString(Data::FormatDateTime(contact.date)) + } + })); + } + } + const auto full = SerializeString(Data::AboutContacts()) + + kLineBreak + + kLineBreak + + JoinList(kLineBreak, list); + if (const auto result = file->writeBlock(full); !result) { + return result; + } else if (const auto closed = file->close(); !closed) { + return closed; + } + + const auto header = SerializeLink( + "Contacts " + "(" + Data::NumberToString(data.list.size()) + ")", + _summary->relativePath(filename)) + + kLineBreak + + kLineBreak; + return _summary->writeBlock(header); +} + +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 = "frequent.html"; + const auto file = fileWithRelativePath(filename); + auto list = std::vector(); + list.reserve(size); + const auto writeList = [&]( + const std::vector &peers, + Data::Utf8String category) { + for (const auto &top : peers) { + const auto user = [&]() -> Data::Utf8String { + if (!top.peer.user() || top.peer.user()->isSelf) { + return Data::Utf8String(); + } else if (top.peer.name().isEmpty()) { + return "(deleted user)"; + } + return top.peer.name(); + }(); + const auto chatType = [&] { + if (const auto chat = top.peer.chat()) { + return chat->username.isEmpty() + ? (chat->isBroadcast + ? "Private channel" + : (chat->isSupergroup + ? "Private supergroup" + : "Private group")) + : (chat->isBroadcast + ? "Public channel" + : "Public supergroup"); + } + return ""; + }(); + const auto chat = [&]() -> Data::Utf8String { + if (!top.peer.chat()) { + return Data::Utf8String(); + } else if (top.peer.name().isEmpty()) { + return "(deleted chat)"; + } + return top.peer.name(); + }(); + const auto saved = [&]() -> Data::Utf8String { + if (!top.peer.user() || !top.peer.user()->isSelf) { + return Data::Utf8String(); + } + return "Saved messages"; + }(); + list.push_back(SerializeKeyValue({ + { "Category", SerializeString(category) }, + { + "User", + top.peer.user() ? SerializeString(user) : QByteArray() + }, + { "Chat", SerializeString(saved) }, + { chatType, SerializeString(chat) }, + { + "Rating", + SerializeString(Data::NumberToString(top.rating)) + } + })); + } + }; + writeList(data.correspondents, "Correspondents"); + writeList(data.inlineBots, "Inline bots"); + writeList(data.phoneCalls, "Calls"); + const auto full = JoinList(kLineBreak, list); + if (const auto result = file->writeBlock(full); !result) { + return result; + } else if (const auto closed = file->close(); !closed) { + return closed; + } + + const auto header = SerializeLink( + "Frequent contacts " + "(" + Data::NumberToString(size) + ")", + _summary->relativePath(filename)) + + kLineBreak + + kLineBreak; + return _summary->writeBlock(header); +} + +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 = "sessions.html"; + const auto file = fileWithRelativePath(filename); + auto list = std::vector(); + list.reserve(data.list.size()); + for (const auto &session : data.list) { + list.push_back(SerializeKeyValue({ + { + "Last active", + SerializeString(Data::FormatDateTime(session.lastActive)) + }, + { "Last IP address", SerializeString(session.ip) }, + { "Last country", SerializeString(session.country) }, + { "Last region", SerializeString(session.region) }, + { + "Application name", + (session.applicationName.isEmpty() + ? Data::Utf8String("(unknown)") + : SerializeString(session.applicationName)) + }, + { + "Application version", + SerializeString(session.applicationVersion) + }, + { "Device model", SerializeString(session.deviceModel) }, + { "Platform", SerializeString(session.platform) }, + { "System version", SerializeString(session.systemVersion) }, + { "Created", Data::FormatDateTime(session.created) }, + })); + } + const auto full = SerializeString(Data::AboutSessions()) + + kLineBreak + + kLineBreak + + JoinList(kLineBreak, list); + if (const auto result = file->writeBlock(full); !result) { + return result; + } else if (const auto closed = file->close(); !closed) { + return closed; + } + + const auto header = SerializeLink( + "Sessions " + "(" + Data::NumberToString(data.list.size()) + ")", + _summary->relativePath(filename)) + + kLineBreak + + kLineBreak; + return _summary->writeBlock(header); +} + +Result HtmlWriter::writeWebSessions(const Data::SessionsList &data) { + Expects(_summary != nullptr); + + if (data.webList.empty()) { + return Result::Success(); + } + + const auto filename = "web_sessions.html"; + const auto file = fileWithRelativePath(filename); + auto list = std::vector(); + list.reserve(data.webList.size()); + for (const auto &session : data.webList) { + list.push_back(SerializeKeyValue({ + { + "Last active", + SerializeString(Data::FormatDateTime(session.lastActive)) + }, + { "Last IP address", SerializeString(session.ip) }, + { "Last region", SerializeString(session.region) }, + { + "Bot username", + (session.botUsername.isEmpty() + ? Data::Utf8String("(unknown)") + : SerializeString(session.botUsername)) + }, + { + "Domain name", + (session.domain.isEmpty() + ? Data::Utf8String("(unknown)") + : SerializeString(session.domain)) + }, + { "Browser", SerializeString(session.browser) }, + { "Platform", SerializeString(session.platform) }, + { + "Created", + SerializeString(Data::FormatDateTime(session.created)) + }, + })); + } + const auto full = JoinList(kLineBreak, list); + if (const auto result = file->writeBlock(full); !result) { + return result; + } else if (const auto closed = file->close(); !closed) { + return closed; + } + + const auto header = SerializeLink( + "Web sessions " + "(" + Data::NumberToString(data.webList.size()) + ")", + _summary->relativePath(filename)) + + kLineBreak + + kLineBreak; + return _summary->writeBlock(header); +} + +Result HtmlWriter::writeDialogsStart(const Data::DialogsInfo &data) { + return writeChatsStart(data, "Chats", "chats.html"); +} + +Result HtmlWriter::writeDialogStart(const Data::DialogInfo &data) { + return writeChatStart(data); +} + +Result HtmlWriter::writeDialogSlice(const Data::MessagesSlice &data) { + return writeChatSlice(data); +} + +Result HtmlWriter::writeDialogEnd() { + return writeChatEnd(); +} + +Result HtmlWriter::writeDialogsEnd() { + return writeChatsEnd(); +} + +Result HtmlWriter::writeLeftChannelsStart(const Data::DialogsInfo &data) { + return writeChatsStart(data, "Left chats", "left_chats.html"); +} + +Result HtmlWriter::writeLeftChannelStart(const Data::DialogInfo &data) { + return writeChatStart(data); +} + +Result HtmlWriter::writeLeftChannelSlice(const Data::MessagesSlice &data) { + return writeChatSlice(data); +} + +Result HtmlWriter::writeLeftChannelEnd() { + return writeChatEnd(); +} + +Result HtmlWriter::writeLeftChannelsEnd() { + return writeChatsEnd(); +} + +Result HtmlWriter::writeChatsStart( + const Data::DialogsInfo &data, + const QByteArray &listName, + const QString &fileName) { + Expects(_summary != nullptr); + Expects(_chats == nullptr); + + if (data.list.empty()) { + return Result::Success(); + } + + _chats = fileWithRelativePath(fileName); + _dialogIndex = 0; + _dialogsCount = data.list.size(); + + const auto header = SerializeLink( + listName + " " + "(" + Data::NumberToString(data.list.size()) + ")", + _summary->relativePath(fileName)) + + kLineBreak + + kLineBreak; + return _summary->writeBlock(header); +} + +Result HtmlWriter::writeChatStart(const Data::DialogInfo &data) { + Expects(_chat == nullptr); + Expects(_dialogIndex < _dialogsCount); + + const auto digits = Data::NumberToString(_dialogsCount - 1).size(); + const auto number = Data::NumberToString(++_dialogIndex, digits, '0'); + _chat = fileWithRelativePath(data.relativePath + "messages.html"); + _messagesCount = 0; + _dialog = data; + return Result::Success(); +} + +Result HtmlWriter::writeChatSlice(const Data::MessagesSlice &data) { + Expects(_chat != nullptr); + Expects(!data.list.empty()); + + _messagesCount += data.list.size(); + auto list = std::vector(); + list.reserve(data.list.size()); + for (const auto &message : data.list) { + list.push_back(SerializeMessage( + [&](QString path) { return _chat->relativePath(path); }, + message, + data.peers, + _settings.internalLinksDomain)); + } + const auto full = _chat->empty() + ? JoinList(kLineBreak, list) + : kLineBreak + JoinList(kLineBreak, list); + return _chat->writeBlock(full); +} + +Result HtmlWriter::writeChatEnd() { + Expects(_chats != nullptr); + Expects(_chat != nullptr); + + if (const auto closed = base::take(_chat)->close(); !closed) { + return closed; + } + + using Type = Data::DialogInfo::Type; + const auto TypeString = [](Type type) { + switch (type) { + case Type::Unknown: return "(unknown)"; + case Type::Self: + case Type::Personal: return "Personal chat"; + case Type::Bot: return "Bot chat"; + case Type::PrivateGroup: return "Private group"; + case Type::PrivateSupergroup: return "Private supergroup"; + case Type::PublicSupergroup: return "Public supergroup"; + case Type::PrivateChannel: return "Private channel"; + case Type::PublicChannel: return "Public channel"; + } + Unexpected("Dialog type in TypeString."); + }; + const auto NameString = []( + const Data::DialogInfo &dialog, + Type type) -> QByteArray { + if (dialog.type == Type::Self) { + return "Saved messages"; + } + const auto name = dialog.name; + if (!name.isEmpty()) { + return name; + } + switch (type) { + case Type::Unknown: return "(unknown)"; + case Type::Personal: return "(deleted user)"; + case Type::Bot: return "(deleted bot)"; + 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."); + }; + return _chats->writeBlock(SerializeKeyValue({ + { "Name", SerializeString(NameString(_dialog, _dialog.type)) }, + { "Type", SerializeString(TypeString(_dialog.type)) }, + { + (_dialog.onlyMyMessages + ? "Outgoing messages count" + : "Messages count"), + SerializeString(Data::NumberToString(_messagesCount)) + }, + { + "Content", + (_messagesCount > 0 + ? SerializeLink( + (_dialog.relativePath + "messages.html").toUtf8(), + _chats->relativePath( + (_dialog.relativePath + "messages.html"))) + : QByteArray()) + } + }) + kLineBreak); +} + +Result HtmlWriter::writeChatsEnd() { + if (_chats) { + return base::take(_chats)->close(); + } + return Result::Success(); +} + +Result HtmlWriter::finish() { + Expects(_summary != nullptr); + + 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(mainFileRelativePath()); +} + +QString HtmlWriter::mainFileRelativePath() const { + return "overview.html"; +} + +QString HtmlWriter::pathWithRelativePath(const QString &path) const { + return _settings.path + path; +} + +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 diff --git a/Telegram/SourceFiles/export/output/export_output_html.h b/Telegram/SourceFiles/export/output/export_output_html.h new file mode 100644 index 00000000000000..a1a8b6ed40cefb --- /dev/null +++ b/Telegram/SourceFiles/export/output/export_output_html.h @@ -0,0 +1,101 @@ +/* +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 +*/ +#pragma once + +#include "export/output/export_output_abstract.h" +#include "export/output/export_output_file.h" +#include "export/export_settings.h" +#include "export/data/export_data_types.h" + +namespace Export { +namespace Output { + +class HtmlWriter : public AbstractWriter { +public: + HtmlWriter(); + + Format format() override { + return Format::Html; + } + + Result start(const Settings &settings, Stats *stats) override; + + Result writePersonal(const Data::PersonalInfo &data) override; + + Result writeUserpicsStart(const Data::UserpicsInfo &data) override; + Result writeUserpicsSlice(const Data::UserpicsSlice &data) override; + Result writeUserpicsEnd() override; + + Result writeContactsList(const Data::ContactsList &data) override; + + Result writeSessionsList(const Data::SessionsList &data) override; + + Result writeDialogsStart(const Data::DialogsInfo &data) override; + Result writeDialogStart(const Data::DialogInfo &data) override; + Result writeDialogSlice(const Data::MessagesSlice &data) override; + Result writeDialogEnd() override; + Result writeDialogsEnd() override; + + Result writeLeftChannelsStart(const Data::DialogsInfo &data) override; + Result writeLeftChannelStart(const Data::DialogInfo &data) override; + Result writeLeftChannelSlice(const Data::MessagesSlice &data) override; + Result writeLeftChannelEnd() override; + Result writeLeftChannelsEnd() override; + + Result finish() override; + + QString mainFilePath() override; + + ~HtmlWriter(); + +private: + class Wrap; + + Result copyFile( + const QString &source, + const QString &relativePath) const; + + QString mainFileRelativePath() const; + QString pathWithRelativePath(const QString &path) const; + std::unique_ptr fileWithRelativePath(const QString &path) const; + + Result writeSavedContacts(const Data::ContactsList &data); + Result writeFrequentContacts(const Data::ContactsList &data); + + Result writeSessions(const Data::SessionsList &data); + Result writeWebSessions(const Data::SessionsList &data); + + Result writeChatsStart( + const Data::DialogsInfo &data, + const QByteArray &listName, + const QString &fileName); + Result writeChatStart(const Data::DialogInfo &data); + Result writeChatSlice(const Data::MessagesSlice &data); + Result writeChatEnd(); + Result writeChatsEnd(); + + Settings _settings; + Stats *_stats = nullptr; + + std::unique_ptr _summary; + + int _userpicsCount = 0; + std::unique_ptr _userpics; + + int _dialogsCount = 0; + int _dialogIndex = 0; + Data::DialogInfo _dialog; + + int _messagesCount = 0; + std::unique_ptr _chats; + std::unique_ptr _chat; + +}; + +} // namespace Output +} // namespace Export diff --git a/Telegram/SourceFiles/export/output/export_output_json.cpp b/Telegram/SourceFiles/export/output/export_output_json.cpp index adc12f4098cbd9..1288e312542eef 100644 --- a/Telegram/SourceFiles/export/output/export_output_json.cpp +++ b/Telegram/SourceFiles/export/output/export_output_json.cpp @@ -733,12 +733,14 @@ Result JsonWriter::writeFrequentContacts(const Data::ContactsList &data) { const auto type = [&] { if (const auto chat = top.peer.chat()) { return chat->username.isEmpty() - ? (chat->broadcast + ? (chat->isBroadcast ? "private_channel" - : "private_group") - : (chat->broadcast + : (chat->isSupergroup + ? "private_supergroup" + : "private_group")) + : (chat->isBroadcast ? "public_channel" - : "public_group"); + : "public_supergroup"); } return "user"; }(); @@ -879,10 +881,12 @@ Result JsonWriter::writeChatStart(const Data::DialogInfo &data) { const auto TypeString = [](Type type) { switch (type) { case Type::Unknown: return ""; + case Type::Self: return "saved_messages"; case Type::Personal: return "personal_chat"; case Type::Bot: return "bot_chat"; case Type::PrivateGroup: return "private_group"; - case Type::PublicGroup: return "public_group"; + case Type::PrivateSupergroup: return "private_supergroup"; + case Type::PublicSupergroup: return "public_supergroup"; case Type::PrivateChannel: return "private_channel"; case Type::PublicChannel: return "public_channel"; } @@ -891,8 +895,10 @@ Result JsonWriter::writeChatStart(const Data::DialogInfo &data) { auto block = prepareArrayItemStart(); block.append(pushNesting(Context::kObject)); - block.append(prepareObjectItemStart("name") - + StringAllowNull(data.name)); + if (data.type != Type::Self) { + block.append(prepareObjectItemStart("name") + + StringAllowNull(data.name)); + } block.append(prepareObjectItemStart("type") + StringAllowNull(TypeString(data.type))); block.append(prepareObjectItemStart("messages")); diff --git a/Telegram/SourceFiles/export/output/export_output_json.h b/Telegram/SourceFiles/export/output/export_output_json.h index 62c024e0cc1c82..49d5355109b9da 100644 --- a/Telegram/SourceFiles/export/output/export_output_json.h +++ b/Telegram/SourceFiles/export/output/export_output_json.h @@ -29,6 +29,10 @@ struct JsonContext { class JsonWriter : public AbstractWriter { public: + Format format() override { + return Format::Json; + } + Result start(const Settings &settings, Stats *stats) override; Result writePersonal(const Data::PersonalInfo &data) override; diff --git a/Telegram/SourceFiles/export/output/export_output_stats.cpp b/Telegram/SourceFiles/export/output/export_output_stats.cpp index 80cb0557ffb82c..30275ace569a23 100644 --- a/Telegram/SourceFiles/export/output/export_output_stats.cpp +++ b/Telegram/SourceFiles/export/output/export_output_stats.cpp @@ -10,6 +10,11 @@ For license and copyright information please follow this link: namespace Export { namespace Output { +Stats::Stats(const Stats &other) +: _files(other._files.load()) +, _bytes(other._bytes.load()) { +} + void Stats::incrementFiles() { ++_files; } diff --git a/Telegram/SourceFiles/export/output/export_output_stats.h b/Telegram/SourceFiles/export/output/export_output_stats.h index 0277bb3c000420..1787e38186f846 100644 --- a/Telegram/SourceFiles/export/output/export_output_stats.h +++ b/Telegram/SourceFiles/export/output/export_output_stats.h @@ -14,6 +14,9 @@ namespace Output { class Stats { public: + Stats() = default; + Stats(const Stats &other); + void incrementFiles(); void incrementBytes(int count); diff --git a/Telegram/SourceFiles/export/output/export_output_text.cpp b/Telegram/SourceFiles/export/output/export_output_text.cpp index 35245bb40271f3..a51d4784fbcd36 100644 --- a/Telegram/SourceFiles/export/output/export_output_text.cpp +++ b/Telegram/SourceFiles/export/output/export_output_text.cpp @@ -580,7 +580,7 @@ Result TextWriter::writeFrequentContacts(const Data::ContactsList &data) { Data::Utf8String category) { for (const auto &top : peers) { const auto user = [&]() -> Data::Utf8String { - if (!top.peer.user()) { + if (!top.peer.user() || top.peer.user()->isSelf) { return Data::Utf8String(); } else if (top.peer.name().isEmpty()) { return "(deleted user)"; @@ -590,12 +590,14 @@ Result TextWriter::writeFrequentContacts(const Data::ContactsList &data) { const auto chatType = [&] { if (const auto chat = top.peer.chat()) { return chat->username.isEmpty() - ? (chat->broadcast + ? (chat->isBroadcast ? "Private channel" - : "Private group") - : (chat->broadcast + : (chat->isSupergroup + ? "Private supergroup" + : "Private group")) + : (chat->isBroadcast ? "Public channel" - : "Public group"); + : "Public supergroup"); } return ""; }(); @@ -607,11 +609,18 @@ Result TextWriter::writeFrequentContacts(const Data::ContactsList &data) { } return top.peer.name(); }(); + const auto saved = [&]() -> Data::Utf8String { + if (!top.peer.user() || !top.peer.user()->isSelf) { + return Data::Utf8String(); + } + return "Saved messages"; + }(); list.push_back(SerializeKeyValue({ { "Category", category }, { "User", top.peer.user() ? user : QByteArray() }, + { "Chat", saved }, { chatType, chat }, - { "Rating", QString::number(top.rating).toUtf8() } + { "Rating", Data::NumberToString(top.rating) } })); } }; @@ -834,18 +843,24 @@ Result TextWriter::writeChatEnd() { const auto TypeString = [](Type type) { switch (type) { case Type::Unknown: return "(unknown)"; + case Type::Self: case Type::Personal: return "Personal chat"; case Type::Bot: return "Bot chat"; case Type::PrivateGroup: return "Private group"; - case Type::PublicGroup: return "Public group"; + case Type::PrivateSupergroup: return "Private supergroup"; + case Type::PublicSupergroup: return "Public supergroup"; case Type::PrivateChannel: return "Private channel"; case Type::PublicChannel: return "Public channel"; } Unexpected("Dialog type in TypeString."); }; const auto NameString = []( - const Data::Utf8String &name, + const Data::DialogInfo &dialog, Type type) -> QByteArray { + if (dialog.type == Type::Self) { + return "Saved messages"; + } + const auto name = dialog.name; if (!name.isEmpty()) { return name; } @@ -854,14 +869,15 @@ Result TextWriter::writeChatEnd() { case Type::Personal: return "(deleted user)"; case Type::Bot: return "(deleted bot)"; case Type::PrivateGroup: - case Type::PublicGroup: return "(deleted group)"; + case Type::PrivateSupergroup: + case Type::PublicSupergroup: return "(deleted group)"; case Type::PrivateChannel: case Type::PublicChannel: return "(deleted channel)"; } Unexpected("Dialog type in TypeString."); }; return _chats->writeBlock(SerializeKeyValue({ - { "Name", NameString(_dialog.name, _dialog.type) }, + { "Name", NameString(_dialog, _dialog.type) }, { "Type", TypeString(_dialog.type) }, { (_dialog.onlyMyMessages diff --git a/Telegram/SourceFiles/export/output/export_output_text.h b/Telegram/SourceFiles/export/output/export_output_text.h index 1f87bbaae8a224..7afa4be7b2d855 100644 --- a/Telegram/SourceFiles/export/output/export_output_text.h +++ b/Telegram/SourceFiles/export/output/export_output_text.h @@ -17,6 +17,10 @@ namespace Output { class TextWriter : public AbstractWriter { public: + Format format() override { + return Format::Text; + } + Result start(const Settings &settings, Stats *stats) override; Result writePersonal(const Data::PersonalInfo &data) override; diff --git a/Telegram/SourceFiles/export/view/export_view_settings.cpp b/Telegram/SourceFiles/export/view/export_view_settings.cpp index 88ced9f3ff7022..b873f4c1353bd2 100644 --- a/Telegram/SourceFiles/export/view/export_view_settings.cpp +++ b/Telegram/SourceFiles/export/view/export_view_settings.cpp @@ -192,7 +192,7 @@ void SettingsWidget::setupPathAndFormat( }; addHeader(container, lng_export_header_format); addLocationLabel(container); - addFormatOption(lng_export_option_text, Format::Text); + addFormatOption(lng_export_option_html, Format::Html); addFormatOption(lng_export_option_json, Format::Json); } diff --git a/Telegram/gyp/Telegram.gyp b/Telegram/gyp/Telegram.gyp index 699068eeeaca2f..65917d9b58f8d3 100644 --- a/Telegram/gyp/Telegram.gyp +++ b/Telegram/gyp/Telegram.gyp @@ -109,6 +109,7 @@ '<@(style_files)', '