From 2a1631247d601e5cb685925ac2c29623021b8b33 Mon Sep 17 00:00:00 2001 From: John Preston Date: Fri, 16 Jun 2023 21:23:40 +0400 Subject: [PATCH] Add stories to data export. --- Telegram/Resources/export_html/css/style.css | 15 + .../export_html/images/section_stories.png | Bin 0 -> 605 bytes .../export_html/images/section_stories@2x.png | Bin 0 -> 1120 bytes Telegram/Resources/langs/lang.strings | 2 + Telegram/Resources/qrc/telegram/export.qrc | 2 + .../export/data/export_data_types.cpp | 103 +++++++ .../export/data/export_data_types.h | 29 ++ .../SourceFiles/export/export_api_wrap.cpp | 271 +++++++++++++++++- Telegram/SourceFiles/export/export_api_wrap.h | 29 +- .../SourceFiles/export/export_controller.cpp | 55 +++- .../SourceFiles/export/export_controller.h | 12 +- Telegram/SourceFiles/export/export_settings.h | 8 +- .../export/output/export_output_abstract.h | 8 + .../export/output/export_output_html.cpp | 210 +++++++++++++- .../export/output/export_output_html.h | 10 + .../export/output/export_output_json.cpp | 71 +++++ .../export/output/export_output_json.h | 4 + .../export/view/export_view_content.cpp | 7 + .../export/view/export_view_settings.cpp | 5 + 19 files changed, 812 insertions(+), 29 deletions(-) create mode 100644 Telegram/Resources/export_html/images/section_stories.png create mode 100644 Telegram/Resources/export_html/images/section_stories@2x.png diff --git a/Telegram/Resources/export_html/css/style.css b/Telegram/Resources/export_html/css/style.css index 456d59f46..79b680cc2 100644 --- a/Telegram/Resources/export_html/css/style.css +++ b/Telegram/Resources/export_html/css/style.css @@ -111,6 +111,11 @@ pre { border-radius: 50%; overflow: hidden; } +.story { + display: block; + border-radius: 4px; + overflow: hidden; +} .userpic .initials { display: block; color: #fff; @@ -194,6 +199,10 @@ a.block_link:hover { text-decoration: none !important; background-color: #f5f7f8; } +a.expanded { + padding: 2px 8px; + margin: -2px -8px; +} .sections { padding: 11px 0; } @@ -428,6 +437,9 @@ div.toast_shown { .section.sessions { background-image: url(../images/section_sessions.png); } +.section.stories { + background-image: url(../images/section_stories.png); +} .section.web { background-image: url(../images/section_web.png); } @@ -489,6 +501,9 @@ div.toast_shown { .section.sessions { background-image: url(../images/section_sessions@2x.png); } +.section.stories { + background-image: url(../images/section_stories@2x.png); +} .section.web { background-image: url(../images/section_web@2x.png); } diff --git a/Telegram/Resources/export_html/images/section_stories.png b/Telegram/Resources/export_html/images/section_stories.png new file mode 100644 index 0000000000000000000000000000000000000000..650c69c91f7966a5e18dedee4cb00a8354291b3f GIT binary patch literal 605 zcmV-j0;2tiP)X1^@s6D=Y3@00009a7bBm000Gv z000Gv0c~iV`Tzg`0drDELIAGL9O(c600d`2O+f$vv5yP6D1^z z9Znp0l4EBwo;SPu<^|v%6~L+l)jQt>q7FZ{0E4y!DShD27$|=>H`h7n@5#a@Y-R3I+pF-2` zzgH3zoo8=S9N`f*0%f#LmNuev6V@}$xrai6!Or)UG#1{X)GnVpr+X9i75o5WgjlKL zGuuq3+|qD1$D8Zu_BKT(b3Jc#^ziFEANC$;J?J-ae$^Ozf3KaUgTB1l4~y)}K6L rkHRfmNP^U0ITLq_kL9`j;}7Et0Ts%$t7y#m00000NkvXXu0mjfil6t< literal 0 HcmV?d00001 diff --git a/Telegram/Resources/export_html/images/section_stories@2x.png b/Telegram/Resources/export_html/images/section_stories@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..429245138d9d9aa5af1375b66d1c69d418e7b93f GIT binary patch literal 1120 zcmV-m1fTnfP)$K-jS;!knOR0^kI=i$WBTa3n|_b&<&llANIF32LgUV%KnjnjOpT z5G3*RjbkV?_KfYsNn6REgmA_)Z{FX$dG8q@M~)mh{w4@y1hZ zD7J8cl(y5HkHi85%zpuhav9b}EmLaSE+l|23=}OCWG~`%Dj^aH5V*|`2s7`Z#&>0H zCxtHvD@G}0fbO)N#GML|1l;H4O$hXGll5tt=kP6dUs^|Eed$#t*fSQf76`&*M_SgW z>Kz5a!WI7r7B#iV$?Ab4Rx>-U5usns8xd=>W3EL&@}M3wyIp+%l~IzV<_|Gu}=c z>XLv`q>1NA8ZHMsD519iBZtCo0^OvMW*SV+6KP94?CI7h1@OaWDBh3jFmI3%l}3z! zmq=(V@L-69i8D{_KO)$}5e9jZsDMTQVu(cWhFugGh}NeY9hH=ae>ym+UYrxXBl6BFOIo(76+oi?UV+|nRO-G4p}JS$ISsH0Fp zy2M6OSe++Aai6_XO;rovfv9c5&AA|i_Trt4XlN`Diu>9tJu3uz7~-hssOBpLh`iF- z@DSz=7Aj;0x)esAjbikXMohC-tH7KPXn!9>9@@G!@fF$e36BQlXG5-EN@iRWDC%w= zLSO6F#aCp6$kkUFH)=uFxEpmKR6McR^gUTu;`kJEbSC*f>%+?O!K{{;X#N2eLiQ#4 zZOnnpxb?}p68B3MfD~2%J8fw4St679zE+vIjm$iE(`73OjTDV7Wea^GT`}4ck-<5EVw+bUq3zP m#kmL9QD-DajvP7uPy7aUhMV9W!l4@g0000../../export_html/images/section_photos@2x.png ../../export_html/images/section_sessions.png ../../export_html/images/section_sessions@2x.png + ../../export_html/images/section_stories.png + ../../export_html/images/section_stories@2x.png ../../export_html/images/section_web.png ../../export_html/images/section_web@2x.png ../../export_html/js/script.js diff --git a/Telegram/SourceFiles/export/data/export_data_types.cpp b/Telegram/SourceFiles/export/data/export_data_types.cpp index e2840acc4..e2bc9fde2 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.cpp +++ b/Telegram/SourceFiles/export/data/export_data_types.cpp @@ -42,6 +42,16 @@ QString PreparePhotoFileName(int index, TimeId date) { + ".jpg"; } +QString PrepareStoryFileName( + int index, + TimeId date, + const Utf8String &extension) { + return "story_" + + QString::number(index) + + PrepareFileNameDatePart(date) + + extension; +} + } // namespace int PeerColorIndex(BareId bareId) { @@ -584,6 +594,99 @@ UserpicsSlice ParseUserpicsSlice( return result; } +File &Story::file() { + return media.file(); +} + +const File &Story::file() const { + return media.file(); +} + +Image &Story::thumb() { + return media.thumb(); +} + +const Image &Story::thumb() const { + return media.thumb(); +} + +StoriesSlice ParseStoriesSlice( + const MTPVector &data, + int baseIndex) { + const auto &list = data.v; + auto result = StoriesSlice(); + result.list.reserve(list.size()); + for (const auto &story : list) { + result.lastId = story.match([](const auto &data) { + return data.vid().v; + }); + ++result.skipped; + story.match([&](const MTPDstoryItem &data) { + const auto date = data.vdate().v; + const auto expires = data.vexpire_date().v; + auto media = Media(); + data.vmedia().match([&](const MTPDmessageMediaPhoto &data) { + const auto suggestedPath = "stories/" + + PrepareStoryFileName( + ++baseIndex, + date, + ".jpg"_q); + const auto photo = data.vphoto(); + auto content = photo + ? ParsePhoto(*photo, suggestedPath) + : Photo(); + media.content = content; + }, [&](const MTPDmessageMediaDocument &data) { + const auto document = data.vdocument(); + auto fake = ParseMediaContext(); + auto content = document + ? ParseDocument(fake, *document, "stories", date) + : Document(); + const auto extension = (content.mime == "image/jpeg") + ? ".jpg"_q + : (content.mime == "image/png") + ? ".png"_q + : [&] { + const auto mimeType = Core::MimeTypeForName( + content.mime); + QStringList patterns = mimeType.globPatterns(); + if (!patterns.isEmpty()) { + return patterns.front().replace( + '*', + QString()).toUtf8(); + } + return QByteArray(); + }(); + const auto path = content.file.suggestedPath = "stories/" + + PrepareStoryFileName( + ++baseIndex, + date, + extension); + content.thumb.file.suggestedPath = path + "_thumb.jpg"; + media.content = content; + }, [&](const auto &data) { + media.content = UnsupportedMedia(); + }); + if (!v::is(media.content)) { + result.list.push_back(Story{ + .id = data.vid().v, + .date = date, + .expires = data.vexpire_date().v, + .media = std::move(media), + .pinned = data.is_pinned(), + .caption = (data.vcaption() + ? ParseText( + *data.vcaption(), + data.ventities().value_or_empty()) + : std::vector()), + }); + --result.skipped; + } + }, [](const auto &) {}); + } + return result; +} + std::pair WriteImageThumb( const QString &basePath, const QString &largePath, diff --git a/Telegram/SourceFiles/export/data/export_data_types.h b/Telegram/SourceFiles/export/data/export_data_types.h index d383fdb94..27fb14ea1 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.h +++ b/Telegram/SourceFiles/export/data/export_data_types.h @@ -48,6 +48,10 @@ struct UserpicsInfo { int count = 0; }; +struct StoriesInfo { + int count = 0; +}; + struct FileLocation { int dcId = 0; MTPInputFileLocation data; @@ -663,9 +667,34 @@ struct FileOrigin { int split = 0; MTPInputPeer peer; int32 messageId = 0; + int32 storyId = 0; uint64 customEmojiId = 0; }; +struct Story { + int32 id = 0; + TimeId date = 0; + TimeId expires = 0; + Media media; + bool pinned = false; + std::vector caption; + + File &file(); + const File &file() const; + Image &thumb(); + const Image &thumb() const; +}; + +struct StoriesSlice { + std::vector list; + int32 lastId = 0; + int skipped = 0; +}; + +StoriesSlice ParseStoriesSlice( + const MTPVector &data, + int baseIndex); + Message ParseMessage( ParseMediaContext &context, const MTPMessage &data, diff --git a/Telegram/SourceFiles/export/export_api_wrap.cpp b/Telegram/SourceFiles/export/export_api_wrap.cpp index 3e31914d4..1ff8d6774 100644 --- a/Telegram/SourceFiles/export/export_api_wrap.cpp +++ b/Telegram/SourceFiles/export/export_api_wrap.cpp @@ -30,6 +30,7 @@ constexpr auto kTopPeerSliceLimit = 100; constexpr auto kFileMaxSize = 4000 * int64(1024 * 1024); constexpr auto kLocationCacheSize = 100'000; constexpr auto kMaxEmojiPerRequest = 100; +constexpr auto kStoriesSliceLimit = 100; struct LocationKey { uint64 type; @@ -109,6 +110,7 @@ struct ApiWrap::StartProcess { enum class Step { UserpicsCount, + StoriesCount, SplitRanges, DialogsCount, LeftChannelsCount, @@ -139,6 +141,19 @@ struct ApiWrap::UserpicsProcess { int fileIndex = 0; }; +struct ApiWrap::StoriesProcess { + FnMut start; + Fn fileProgress; + Fn handleSlice; + FnMut finish; + + int processed = 0; + std::optional slice; + int offsetId = 0; + bool lastSlice = false; + int fileIndex = 0; +}; + struct ApiWrap::OtherDataProcess { Data::File file; FnMut done; @@ -417,6 +432,9 @@ void ApiWrap::startExport( if (_settings->types & Settings::Type::Userpics) { _startProcess->steps.push_back(Step::UserpicsCount); } + if (_settings->types & Settings::Type::Stories) { + _startProcess->steps.push_back(Step::StoriesCount); + } if (_settings->types & Settings::Type::AnyChatsMask) { _startProcess->steps.push_back(Step::SplitRanges); } @@ -447,6 +465,8 @@ void ApiWrap::sendNextStartRequest() { switch (step) { case Step::UserpicsCount: return requestUserpicsCount(); + case Step::StoriesCount: + return requestStoriesCount(); case Step::SplitRanges: return requestSplitRanges(); case Step::DialogsCount: @@ -480,6 +500,22 @@ void ApiWrap::requestUserpicsCount() { }).send(); } +void ApiWrap::requestStoriesCount() { + Expects(_startProcess != nullptr); + + mainRequest(MTPstories_GetStoriesArchive( + MTP_int(0), // offset_id + MTP_int(0) // limit + )).done([=](const MTPstories_Stories &result) { + Expects(_settings != nullptr); + Expects(_startProcess != nullptr); + + _startProcess->info.storiesCount = result.data().vcount().v; + + sendNextStartRequest(); + }).send(); +} + void ApiWrap::requestSplitRanges() { Expects(_startProcess != nullptr); @@ -616,7 +652,8 @@ void ApiWrap::startMainSession(FnMut done) { using Type = Settings::Type; const auto sizeLimit = _settings->media.sizeLimit; const auto hasFiles = ((_settings->media.types != 0) && (sizeLimit > 0)) - || (_settings->types & Type::Userpics); + || (_settings->types & Type::Userpics) + || (_settings->types & Type::Stories); using Flag = MTPaccount_InitTakeoutSession::Flag; const auto flags = Flag(0) @@ -856,6 +893,171 @@ void ApiWrap::finishUserpics() { base::take(_userpicsProcess)->finish(); } +void ApiWrap::requestStories( + FnMut start, + Fn progress, + Fn slice, + FnMut finish) { + Expects(_storiesProcess == nullptr); + + _storiesProcess = std::make_unique(); + _storiesProcess->start = std::move(start); + _storiesProcess->fileProgress = std::move(progress); + _storiesProcess->handleSlice = std::move(slice); + _storiesProcess->finish = std::move(finish); + + mainRequest(MTPstories_GetStoriesArchive( + MTP_int(_storiesProcess->offsetId), + MTP_int(kStoriesSliceLimit) + )).done([=](const MTPstories_Stories &result) mutable { + Expects(_storiesProcess != nullptr); + + auto startInfo = Data::StoriesInfo{ result.data().vcount().v }; + if (!_storiesProcess->start(std::move(startInfo))) { + return; + } + + handleStoriesSlice(result); + }).send(); +} + +void ApiWrap::handleStoriesSlice(const MTPstories_Stories &result) { + Expects(_storiesProcess != nullptr); + + loadStoriesFiles(Data::ParseStoriesSlice( + result.data().vstories(), + _storiesProcess->processed)); +} + +void ApiWrap::loadStoriesFiles(Data::StoriesSlice &&slice) { + Expects(_storiesProcess != nullptr); + Expects(!_storiesProcess->slice.has_value()); + + if (!slice.lastId) { + _storiesProcess->lastSlice = true; + } + _storiesProcess->slice = std::move(slice); + _storiesProcess->fileIndex = 0; + loadNextStory(); +} + +void ApiWrap::loadNextStory() { + Expects(_storiesProcess != nullptr); + Expects(_storiesProcess->slice.has_value()); + + for (auto &list = _storiesProcess->slice->list + ; _storiesProcess->fileIndex < list.size() + ; ++_storiesProcess->fileIndex) { + auto &story = list[_storiesProcess->fileIndex]; + const auto origin = Data::FileOrigin{ .storyId = story.id }; + const auto ready = processFileLoad( + story.file(), + origin, + [=](FileProgress value) { return loadStoryProgress(value); }, + [=](const QString &path) { loadStoryDone(path); }); + if (!ready) { + return; + } + const auto thumbProgress = [=](FileProgress value) { + return loadStoryThumbProgress(value); + }; + const auto thumbReady = processFileLoad( + story.thumb().file, + origin, + thumbProgress, + [=](const QString &path) { loadStoryThumbDone(path); }, + nullptr, + &story); + if (!thumbReady) { + return; + } + } + finishStoriesSlice(); +} + +void ApiWrap::finishStoriesSlice() { + Expects(_storiesProcess != nullptr); + Expects(_storiesProcess->slice.has_value()); + + auto slice = *base::take(_storiesProcess->slice); + if (slice.lastId) { + _storiesProcess->processed += slice.list.size(); + _storiesProcess->offsetId = slice.lastId; + if (!_storiesProcess->handleSlice(std::move(slice))) { + return; + } + } + if (_storiesProcess->lastSlice) { + finishStories(); + return; + } + + mainRequest(MTPstories_GetStoriesArchive( + MTP_int(_storiesProcess->offsetId), + MTP_int(kStoriesSliceLimit) + )).done([=](const MTPstories_Stories &result) { + handleStoriesSlice(result); + }).send(); +} + +bool ApiWrap::loadStoryProgress(FileProgress progress) { + Expects(_fileProcess != nullptr); + Expects(_storiesProcess != nullptr); + Expects(_storiesProcess->slice.has_value()); + Expects((_storiesProcess->fileIndex >= 0) + && (_storiesProcess->fileIndex + < _storiesProcess->slice->list.size())); + + return _storiesProcess->fileProgress(DownloadProgress{ + _fileProcess->randomId, + _fileProcess->relativePath, + _storiesProcess->fileIndex, + progress.ready, + progress.total }); +} + +void ApiWrap::loadStoryDone(const QString &relativePath) { + Expects(_storiesProcess != nullptr); + Expects(_storiesProcess->slice.has_value()); + Expects((_storiesProcess->fileIndex >= 0) + && (_storiesProcess->fileIndex + < _storiesProcess->slice->list.size())); + + const auto index = _storiesProcess->fileIndex; + auto &file = _storiesProcess->slice->list[index].file(); + file.relativePath = relativePath; + if (relativePath.isEmpty()) { + file.skipReason = Data::File::SkipReason::Unavailable; + } + loadNextStory(); +} + +bool ApiWrap::loadStoryThumbProgress(FileProgress progress) { + return loadStoryProgress(progress); +} + +void ApiWrap::loadStoryThumbDone(const QString &relativePath) { + Expects(_storiesProcess != nullptr); + Expects(_storiesProcess->slice.has_value()); + Expects((_storiesProcess->fileIndex >= 0) + && (_storiesProcess->fileIndex + < _storiesProcess->slice->list.size())); + + const auto index = _storiesProcess->fileIndex; + auto &file = _storiesProcess->slice->list[index].thumb().file; + file.relativePath = relativePath; + if (relativePath.isEmpty()) { + file.skipReason = Data::File::SkipReason::Unavailable; + } + loadNextStory(); +} + +void ApiWrap::finishStories() { + Expects(_storiesProcess != nullptr); + + base::take(_storiesProcess)->finish(); +} + void ApiWrap::requestContacts(FnMut done) { Expects(_contactsProcess == nullptr); @@ -1753,7 +1955,8 @@ bool ApiWrap::processFileLoad( const Data::FileOrigin &origin, Fn progress, FnMut done, - Data::Message *message) { + Data::Message *message, + Data::Story *story) { using SkipReason = Data::File::SkipReason; if (!file.relativePath.isEmpty() @@ -1767,7 +1970,12 @@ bool ApiWrap::processFileLoad( } using Type = MediaSettings::Type; - const auto type = message ? v::match(message->media.content, [&]( + const auto media = message + ? &message->media + : story + ? &story->media + : nullptr; + const auto type = media ? v::match(media->content, [&]( const Data::Document &data) { if (data.isSticker) { return Type::Sticker; @@ -1786,14 +1994,18 @@ bool ApiWrap::processFileLoad( return Type::Photo; }) : Type(0); - const auto limit = _settings->media.sizeLimit; + const auto fullSize = message + ? message->file().size + : story + ? story->file().size + : file.size; if (message && Data::SkipMessageByDate(*message, *_settings)) { file.skipReason = SkipReason::DateLimits; return true; - } else if ((_settings->media.types & type) != type) { + } else if (!story && (_settings->media.types & type) != type) { file.skipReason = SkipReason::FileType; return true; - } else if ((message ? message->file().size : file.size) >= limit) { + } else if (!story && fullSize >= _settings->media.sizeLimit) { // Don't load thumbs for large files that we skip. file.skipReason = SkipReason::FileSize; return true; @@ -1972,7 +2184,20 @@ void ApiWrap::filePartRefreshReference(int64 offset) { Expects(_fileProcess->requestId == 0); const auto &origin = _fileProcess->origin; - if (!origin.messageId) { + if (origin.storyId) { + _fileProcess->requestId = mainRequest(MTPstories_GetStoriesByID( + MTP_inputUserSelf(), + MTP_vector(1, MTP_int(origin.storyId)) + )).fail([=](const MTP::Error &error) { + _fileProcess->requestId = 0; + filePartUnavailable(); + return true; + }).done([=](const MTPstories_Stories &result) { + _fileProcess->requestId = 0; + filePartExtractReference(offset, result); + }).send(); + return; + } else if (!origin.messageId) { error("FILE_REFERENCE error for non-message file."); return; } @@ -2061,6 +2286,38 @@ void ApiWrap::filePartExtractReference( }); } +void ApiWrap::filePartExtractReference( + int64 offset, + const MTPstories_Stories &result) { + Expects(_fileProcess != nullptr); + Expects(_fileProcess->requestId == 0); + + const auto stories = Data::ParseStoriesSlice( + result.data().vstories(), + 0); + for (const auto &story : stories.list) { + if (story.id == _fileProcess->origin.storyId) { + const auto refresh1 = Data::RefreshFileReference( + _fileProcess->location, + story.file().location); + const auto refresh2 = Data::RefreshFileReference( + _fileProcess->location, + story.thumb().file.location); + if (refresh1 || refresh2) { + _fileProcess->requestId = fileRequest( + _fileProcess->location, + offset + ).done([=](const MTPupload_File &result) { + _fileProcess->requestId = 0; + filePartDone(offset, result); + }).send(); + return; + } + } + } + filePartUnavailable(); +} + void ApiWrap::filePartUnavailable() { Expects(_fileProcess != nullptr); Expects(!_fileProcess->requests.empty()); diff --git a/Telegram/SourceFiles/export/export_api_wrap.h b/Telegram/SourceFiles/export/export_api_wrap.h index 6384457d7..4723cd882 100644 --- a/Telegram/SourceFiles/export/export_api_wrap.h +++ b/Telegram/SourceFiles/export/export_api_wrap.h @@ -19,12 +19,15 @@ struct FileLocation; struct PersonalInfo; struct UserpicsInfo; struct UserpicsSlice; +struct StoriesInfo; +struct StoriesSlice; struct ContactsList; struct SessionsList; struct DialogsInfo; struct DialogInfo; struct MessagesSlice; struct Message; +struct Story; struct FileOrigin; } // namespace Data @@ -44,6 +47,7 @@ public: struct StartInfo { int userpicsCount = 0; + int storiesCount = 0; int dialogsCount = 0; }; void startExport( @@ -74,6 +78,12 @@ public: Fn slice, FnMut finish); + void requestStories( + FnMut start, + Fn progress, + Fn slice, + FnMut finish); + void requestContacts(FnMut done); void requestSessions(FnMut done); @@ -96,6 +106,7 @@ private: struct StartProcess; struct ContactsProcess; struct UserpicsProcess; + struct StoriesProcess; struct OtherDataProcess; struct FileProcess; struct FileProgress; @@ -107,6 +118,7 @@ private: void startMainSession(FnMut done); void sendNextStartRequest(); void requestUserpicsCount(); + void requestStoriesCount(); void requestSplitRanges(); void requestDialogsCount(); void requestLeftChannelsCount(); @@ -122,6 +134,16 @@ private: void finishUserpicsSlice(); void finishUserpics(); + void handleStoriesSlice(const MTPstories_Stories &result); + void loadStoriesFiles(Data::StoriesSlice &&slice); + void loadNextStory(); + bool loadStoryProgress(FileProgress value); + void loadStoryDone(const QString &relativePath); + bool loadStoryThumbProgress(FileProgress value); + void loadStoryThumbDone(const QString &relativePath); + void finishStoriesSlice(); + void finishStories(); + void otherDataDone(const QString &relativePath); bool useOnlyLastSplit() const; @@ -179,7 +201,8 @@ private: const Data::FileOrigin &origin, Fn progress, FnMut done, - Data::Message *message = nullptr); + Data::Message *message = nullptr, + Data::Story *story = nullptr); std::unique_ptr prepareFileProcess( const Data::File &file, const Data::FileOrigin &origin) const; @@ -198,6 +221,9 @@ private: void filePartExtractReference( int64 offset, const MTPmessages_Messages &result); + void filePartExtractReference( + int64 offset, + const MTPstories_Stories &result); template class RequestBuilder; @@ -228,6 +254,7 @@ private: std::unique_ptr _fileCache; std::unique_ptr _contactsProcess; std::unique_ptr _userpicsProcess; + std::unique_ptr _storiesProcess; std::unique_ptr _otherDataProcess; std::unique_ptr _fileProcess; std::unique_ptr _leftChannelsProcess; diff --git a/Telegram/SourceFiles/export/export_controller.cpp b/Telegram/SourceFiles/export/export_controller.cpp index 516c75cf7..b3d543f4c 100644 --- a/Telegram/SourceFiles/export/export_controller.cpp +++ b/Telegram/SourceFiles/export/export_controller.cpp @@ -75,6 +75,7 @@ private: void collectDialogsList(); void exportPersonalInfo(); void exportUserpics(); + void exportStories(); void exportContacts(); void exportSessions(); void exportOtherData(); @@ -89,6 +90,7 @@ private: ProcessingState stateDialogsList(int processed) const; ProcessingState statePersonalInfo() const; ProcessingState stateUserpics(const DownloadProgress &progress) const; + ProcessingState stateStories(const DownloadProgress &progress) const; ProcessingState stateContacts() const; ProcessingState stateSessions() const; ProcessingState stateOtherData() const; @@ -114,6 +116,9 @@ private: int _userpicsWritten = 0; int _userpicsCount = 0; + int _storiesWritten = 0; + int _storiesCount = 0; + // rpl::variable fails to compile in MSVC :( State _state; rpl::event_stream _stateChanges; @@ -273,6 +278,9 @@ void ControllerObject::fillExportSteps() { if (_settings.types & Type::Userpics) { _steps.push_back(Step::Userpics); } + if (_settings.types & Type::Stories) { + _steps.push_back(Step::Stories); + } if (_settings.types & Type::Contacts) { _steps.push_back(Step::Contacts); } @@ -306,6 +314,9 @@ void ControllerObject::fillSubstepsInSteps(const ApiWrap::StartInfo &info) { if (_settings.types & Settings::Type::Userpics) { push(Step::Userpics, 1); } + if (_settings.types & Settings::Type::Stories) { + push(Step::Stories, 1); + } if (_settings.types & Settings::Type::Contacts) { push(Step::Contacts, 1); } @@ -344,6 +355,7 @@ void ControllerObject::exportNext() { case Step::DialogsList: return collectDialogsList(); case Step::PersonalInfo: return exportPersonalInfo(); case Step::Userpics: return exportUserpics(); + case Step::Stories: return exportStories(); case Step::Contacts: return exportContacts(); case Step::Sessions: return exportSessions(); case Step::OtherData: return exportOtherData(); @@ -416,6 +428,32 @@ void ControllerObject::exportUserpics() { }); } +void ControllerObject::exportStories() { + _api.requestStories([=](Data::StoriesInfo &&start) { + if (ioCatchError(_writer->writeStoriesStart(start))) { + return false; + } + _storiesWritten = 0; + _storiesCount = start.count; + return true; + }, [=](DownloadProgress progress) { + setState(stateStories(progress)); + return true; + }, [=](Data::StoriesSlice &&slice) { + if (ioCatchError(_writer->writeStoriesSlice(slice))) { + return false; + } + _storiesWritten += slice.list.size(); + setState(stateStories(DownloadProgress())); + return true; + }, [=] { + if (ioCatchError(_writer->writeStoriesEnd())) { + return; + } + exportNext(); + }); +} + void ControllerObject::exportContacts() { setState(stateContacts()); _api.requestContacts([=](Data::ContactsList &&result) { @@ -533,7 +571,21 @@ ProcessingState ControllerObject::stateUserpics( return prepareState(Step::Userpics, [&](ProcessingState &result) { result.entityIndex = _userpicsWritten + progress.itemIndex; result.entityCount = std::max(_userpicsCount, result.entityIndex); - result.bytesType = ProcessingState::FileType::Photo; + result.bytesRandomId = progress.randomId; + if (!progress.path.isEmpty()) { + const auto last = progress.path.lastIndexOf('/'); + result.bytesName = progress.path.mid(last + 1); + } + result.bytesLoaded = progress.ready; + result.bytesCount = progress.total; + }); +} + +ProcessingState ControllerObject::stateStories( + const DownloadProgress &progress) const { + return prepareState(Step::Stories, [&](ProcessingState &result) { + result.entityIndex = _storiesWritten + progress.itemIndex; + result.entityCount = std::max(_storiesCount, result.entityIndex); result.bytesRandomId = progress.randomId; if (!progress.path.isEmpty()) { const auto last = progress.path.lastIndexOf('/'); @@ -586,7 +638,6 @@ void ControllerObject::fillMessagesState( : ProcessingState::EntityType::Chat; result.itemIndex = _messagesWritten + progress.itemIndex; result.itemCount = std::max(_messagesCount, result.itemIndex); - result.bytesType = ProcessingState::FileType::File; // TODO result.bytesRandomId = progress.randomId; if (!progress.path.isEmpty()) { const auto last = progress.path.lastIndexOf('/'); diff --git a/Telegram/SourceFiles/export/export_controller.h b/Telegram/SourceFiles/export/export_controller.h index e9b08acdc..fae4b54a1 100644 --- a/Telegram/SourceFiles/export/export_controller.h +++ b/Telegram/SourceFiles/export/export_controller.h @@ -38,21 +38,12 @@ struct ProcessingState { DialogsList, PersonalInfo, Userpics, + Stories, Contacts, Sessions, OtherData, Dialogs, }; - enum class FileType { - None, - Photo, - Video, - VoiceMessage, - VideoMessage, - Sticker, - GIF, - File, - }; enum class EntityType { Chat, SavedMessages, @@ -75,7 +66,6 @@ struct ProcessingState { int itemCount = 0; uint64 bytesRandomId = 0; - FileType bytesType = FileType::None; QString bytesName; int64 bytesLoaded = 0; int64 bytesCount = 0; diff --git a/Telegram/SourceFiles/export/export_settings.h b/Telegram/SourceFiles/export/export_settings.h index beac47760..be2e1b782 100644 --- a/Telegram/SourceFiles/export/export_settings.h +++ b/Telegram/SourceFiles/export/export_settings.h @@ -57,13 +57,18 @@ struct Settings { PublicGroups = 0x100, PrivateChannels = 0x200, PublicChannels = 0x400, + Stories = 0x800, GroupsMask = PrivateGroups | PublicGroups, ChannelsMask = PrivateChannels | PublicChannels, GroupsChannelsMask = GroupsMask | ChannelsMask, NonChannelChatsMask = PersonalChats | BotChats | PrivateGroups, AnyChatsMask = PersonalChats | BotChats | GroupsChannelsMask, - NonChatsMask = PersonalInfo | Userpics | Contacts | Sessions, + NonChatsMask = (PersonalInfo + | Userpics + | Contacts + | Stories + | Sessions), AllMask = NonChatsMask | OtherData | AnyChatsMask, }; using Types = base::flags; @@ -91,6 +96,7 @@ struct Settings { return Type::PersonalInfo | Type::Userpics | Type::Contacts + | Type::Stories | Type::PersonalChats | Type::PrivateGroups; } diff --git a/Telegram/SourceFiles/export/output/export_output_abstract.h b/Telegram/SourceFiles/export/output/export_output_abstract.h index b98f65422..7a2bb65ea 100644 --- a/Telegram/SourceFiles/export/output/export_output_abstract.h +++ b/Telegram/SourceFiles/export/output/export_output_abstract.h @@ -14,6 +14,8 @@ namespace Data { struct PersonalInfo; struct UserpicsInfo; struct UserpicsSlice; +struct StoriesInfo; +struct StoriesSlice; struct ContactsList; struct SessionsList; struct DialogsInfo; @@ -55,6 +57,12 @@ public: const Data::UserpicsSlice &data) = 0; [[nodiscard]] virtual Result writeUserpicsEnd() = 0; + [[nodiscard]] virtual Result writeStoriesStart( + const Data::StoriesInfo &data) = 0; + [[nodiscard]] virtual Result writeStoriesSlice( + const Data::StoriesSlice &data) = 0; + [[nodiscard]] virtual Result writeStoriesEnd() = 0; + [[nodiscard]] virtual Result writeContactsList( const Data::ContactsList &data) = 0; diff --git a/Telegram/SourceFiles/export/output/export_output_html.cpp b/Telegram/SourceFiles/export/output/export_output_html.cpp index 00c4155ad..efa4a7599 100644 --- a/Telegram/SourceFiles/export/output/export_output_html.cpp +++ b/Telegram/SourceFiles/export/output/export_output_html.cpp @@ -35,11 +35,23 @@ 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 kSessionsPriority = 6; +constexpr auto kWebSessionsPriority = 7; +constexpr auto kOtherPriority = 8; const auto kLineBreak = QByteArrayLiteral("
"); using Context = details::HtmlContext; using UserpicData = details::UserpicData; +using StoryData = details::StoryData; using PeersMap = details::PeersMap; using MediaData = details::MediaData; @@ -347,6 +359,11 @@ struct UserpicData { QByteArray lastName; }; +struct StoryData { + QString imageLink; + QString largeLink; +}; + class PeersMap { public: using Peer = Data::Peer; @@ -503,6 +520,14 @@ public: 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 pushSessionListEntry( int apiId, const QByteArray &name, @@ -750,6 +775,75 @@ QByteArray HtmlWriter::Wrap::pushListEntry( 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::pushSessionListEntry( int apiId, const QByteArray &name, @@ -1980,6 +2074,7 @@ Result HtmlWriter::start( "images/section_other.png", "images/section_photos.png", "images/section_sessions.png", + "images/section_stories.png", "images/section_web.png", "js/script.js", }; @@ -2176,13 +2271,114 @@ QString HtmlWriter::userpicsFilePath() const { void HtmlWriter::pushUserpicsSection() { pushSection( - 4, + 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(); +} + +QString HtmlWriter::storiesFilePath() const { + return "lists/stories.html"; +} + +void HtmlWriter::pushStoriesSection() { + pushSection( + kStoriesPriority, + "Stories archive", + "stories", + _storiesCount, + storiesFilePath()); +} + Result HtmlWriter::writeContactsList(const Data::ContactsList &data) { Expects(_summary != nullptr); @@ -2228,7 +2424,7 @@ Result HtmlWriter::writeSavedContacts(const Data::ContactsList &data) { } pushSection( - 2, + kContactsPriority, "Contacts", "contacts", data.list.size(), @@ -2294,7 +2490,7 @@ Result HtmlWriter::writeFrequentContacts(const Data::ContactsList &data) { } pushSection( - 3, + kFrequentContactsPriority, "Frequent contacts", "frequent", size, @@ -2360,7 +2556,7 @@ Result HtmlWriter::writeSessions(const Data::SessionsList &data) { } pushSection( - 5, + kSessionsPriority, "Sessions", "sessions", data.list.size(), @@ -2406,7 +2602,7 @@ Result HtmlWriter::writeWebSessions(const Data::SessionsList &data) { } pushSection( - 6, + kWebSessionsPriority, "Web sessions", "web", data.webList.size(), @@ -2418,7 +2614,7 @@ Result HtmlWriter::writeOtherData(const Data::File &data) { Expects(_summary != nullptr); pushSection( - 7, + kOtherPriority, "Other data", "other", 1, @@ -2447,7 +2643,7 @@ Result HtmlWriter::writeDialogsStart(const Data::DialogsInfo &data) { } pushSection( - 0, + kChatsPriority, "Chats", "chats", data.chats.size() + data.left.size(), diff --git a/Telegram/SourceFiles/export/output/export_output_html.h b/Telegram/SourceFiles/export/output/export_output_html.h index a2235d130..c1dee2ffd 100644 --- a/Telegram/SourceFiles/export/output/export_output_html.h +++ b/Telegram/SourceFiles/export/output/export_output_html.h @@ -35,6 +35,7 @@ private: }; struct UserpicData; +struct StoryData; class PeersMap; struct MediaData; @@ -59,6 +60,10 @@ public: Result writeUserpicsSlice(const Data::UserpicsSlice &data) override; Result writeUserpicsEnd() override; + Result writeStoriesStart(const Data::StoriesInfo &data) override; + Result writeStoriesSlice(const Data::StoriesSlice &data) override; + Result writeStoriesEnd() override; + Result writeContactsList(const Data::ContactsList &data) override; Result writeSessionsList(const Data::SessionsList &data) override; @@ -125,8 +130,10 @@ private: const Data::PersonalInfo &data, const QString &userpicPath); void pushUserpicsSection(); + void pushStoriesSection(); [[nodiscard]] QString userpicsFilePath() const; + [[nodiscard]] QString storiesFilePath() const; [[nodiscard]] QByteArray wrapMessageLink( int messageId, @@ -149,6 +156,9 @@ private: int _userpicsCount = 0; std::unique_ptr _userpics; + int _storiesCount = 0; + std::unique_ptr _stories; + QString _dialogsRelativePath; Data::DialogInfo _dialog; DialogsMode _dialogsMode = DialogsMode::None; diff --git a/Telegram/SourceFiles/export/output/export_output_json.cpp b/Telegram/SourceFiles/export/output/export_output_json.cpp index 1c6ba2c87..9dfd21620 100644 --- a/Telegram/SourceFiles/export/output/export_output_json.cpp +++ b/Telegram/SourceFiles/export/output/export_output_json.cpp @@ -887,6 +887,77 @@ Result JsonWriter::writeUserpicsEnd() { return _output->writeBlock(popNesting()); } +Result JsonWriter::writeStoriesStart(const Data::StoriesInfo &data) { + Expects(_output != nullptr); + + auto block = prepareObjectItemStart("stories"); + return _output->writeBlock(block + pushNesting(Context::kArray)); +} + +Result JsonWriter::writeStoriesSlice(const Data::StoriesSlice &data) { + Expects(_output != nullptr); + + if (data.list.empty()) { + return Result::Success(); + } + + auto block = QByteArray(); + for (const auto &story : data.list) { + using SkipReason = Data::File::SkipReason; + const auto &file = story.file(); + Assert(!file.relativePath.isEmpty() + || file.skipReason != SkipReason::None); + const auto path = [&]() -> 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 FormatFilePath(file); + } + Unexpected("Skip reason while writing story path."); + }(); + block.append(prepareArrayItemStart()); + block.append(SerializeObject(_context, { + { + "date", + story.date ? SerializeDate(story.date) : QByteArray() + }, + { + "date_unixtime", + story.date ? SerializeDateRaw(story.date) : QByteArray() + }, + { + "expires", + story.expires ? SerializeDate(story.expires) : QByteArray() + }, + { + "expires_unixtime", + story.expires ? SerializeDateRaw(story.expires) : QByteArray() + }, + { + "pinned", + story.pinned ? "true" : "false" + }, + { + "media", + SerializeString(path) + }, + })); + } + return _output->writeBlock(block); +} + +Result JsonWriter::writeStoriesEnd() { + Expects(_output != nullptr); + + return _output->writeBlock(popNesting()); +} + Result JsonWriter::writeContactsList(const Data::ContactsList &data) { Expects(_output != nullptr); diff --git a/Telegram/SourceFiles/export/output/export_output_json.h b/Telegram/SourceFiles/export/output/export_output_json.h index 49f7035b0..879918fce 100644 --- a/Telegram/SourceFiles/export/output/export_output_json.h +++ b/Telegram/SourceFiles/export/output/export_output_json.h @@ -44,6 +44,10 @@ public: Result writeUserpicsSlice(const Data::UserpicsSlice &data) override; Result writeUserpicsEnd() override; + Result writeStoriesStart(const Data::StoriesInfo &data) override; + Result writeStoriesSlice(const Data::StoriesSlice &data) override; + Result writeStoriesEnd() override; + Result writeContactsList(const Data::ContactsList &data) override; Result writeSessionsList(const Data::SessionsList &data) override; diff --git a/Telegram/SourceFiles/export/view/export_view_content.cpp b/Telegram/SourceFiles/export/view/export_view_content.cpp index fac8b3d76..9813321ef 100644 --- a/Telegram/SourceFiles/export/view/export_view_content.cpp +++ b/Telegram/SourceFiles/export/view/export_view_content.cpp @@ -89,6 +89,13 @@ Content ContentFromState( case Step::Contacts: pushMain(tr::lng_export_option_contacts(tr::now)); break; + case Step::Stories: + pushMain(tr::lng_export_option_stories(tr::now)); + pushBytes( + "story" + QString::number(state.entityIndex), + state.bytesName, + state.bytesRandomId); + break; case Step::Sessions: pushMain(tr::lng_export_option_sessions(tr::now)); break; diff --git a/Telegram/SourceFiles/export/view/export_view_settings.cpp b/Telegram/SourceFiles/export/view/export_view_settings.cpp index d01d4eb53..94e00b43a 100644 --- a/Telegram/SourceFiles/export/view/export_view_settings.cpp +++ b/Telegram/SourceFiles/export/view/export_view_settings.cpp @@ -173,6 +173,11 @@ void SettingsWidget::setupFullExportOptions( tr::lng_export_option_contacts(tr::now), Type::Contacts, tr::lng_export_option_contacts_about(tr::now)); + addOptionWithAbout( + container, + tr::lng_export_option_stories(tr::now), + Type::Stories, + tr::lng_export_option_stories_about(tr::now)); addHeader(container, tr::lng_export_header_chats(tr::now)); addOption( container,