Compare commits

..

182 Commits

Author SHA1 Message Date
Alexey Golub 71e6a8de7a Update version 2019-06-15 18:16:17 +03:00
Alexey Golub 592b8b6294 Update nuget packages 2019-06-15 18:09:56 +03:00
Alexey Golub e6edcd27a4 [CSV] Render reactions
Addresses the other part of #168
2019-06-11 22:45:19 +03:00
Alexey Golub 557b5be844 [TXT] Render reactions
Addresses part of #168
2019-06-11 22:39:17 +03:00
Alexey Golub 02fe2a46e8 [TXT] Add support for embeds and change how attachments are rendered
Closes #165
2019-06-10 22:16:37 +03:00
Alexey Golub 76c7f9b419 Update version 2019-06-06 23:38:06 +03:00
Alexey Golub 9a383d2bd4 Convert from DateTime to DateTimeOffset instead of parsing it directly
Json.NET is really not meant to be used with DateTimeOffset it seems

Fixes #179
2019-06-06 23:31:40 +03:00
Oleksii Holub 2ed374ee5c [HTML] Render user ID inside data-user-id attribute
Closes #93
2019-06-06 17:43:30 +03:00
Alexey Golub 0ced31ca4a Update version 2019-05-15 01:04:21 +03:00
Alexey Golub 3bb5638b56 Improve usage instructions slightly 2019-05-15 00:56:50 +03:00
Malcolm Diller 3b5d752c95 Add bot tags and some CSS fixes (#172) 2019-05-09 13:52:46 +03:00
Alexey Golub 2b1f599cbe Add logo image 2019-04-25 22:14:17 +03:00
Alexey Golub 7cb1c21897 Refactor 2019-04-18 22:21:20 +03:00
Alexey Golub 37bcea86e3 [CLI] Update usage guide to explain steps needed to extract DM channel ID 2019-04-18 22:19:26 +03:00
Alexey Golub 559ef71d2f Get rid of Chocolatey 2019-04-17 00:59:22 +03:00
Alexey Golub ab0ce478db Move UpdateService to GUI project 2019-04-16 23:08:38 +03:00
Alexey Golub afb9f52221 Update FodyWeavers.xsd 2019-04-16 23:08:22 +03:00
Alexey Golub d65ca409b1 Update nuget packages 2019-04-13 02:42:03 +03:00
Oleksii Holub 7784383ea7 Fix grammar mistakes in comments 2019-04-12 17:13:12 +03:00
Oleksii Holub 26322da800 [GUI] Notify when all exports are done instead of each of them separately 2019-04-12 16:24:17 +03:00
Oleksii Holub 1325edb135 Use latest C# version in all projects 2019-04-12 16:10:28 +03:00
Oleksii Holub dec2c26d83 Update appveyor image to vs2019 2019-04-12 16:01:01 +03:00
Oleksii Holub 82b0b8cb0a [GUI] Block UI when an export is underway 2019-04-12 15:23:07 +03:00
Oleksii Holub a7da90943f Remove trailing whitespace 2019-04-12 13:48:13 +03:00
Alexey Golub 1edeaf9d3a Update changelog 2019-04-12 01:03:42 +03:00
Alexey Golub 9c043376f9 Update version 2019-04-12 00:14:54 +03:00
Alexey Golub 77366cc9b4 Escape specific emojis in markdown
Fixes a part of #146
2019-04-12 00:11:01 +03:00
Alexey Golub ffdca7b799 [HTML] Fix multiple emojis not getting jumbo 2019-04-11 23:31:29 +03:00
Alexey Golub 30cba7959f Refactor string checks and fix exception when exporting multiple channels
Fix #164
2019-04-11 22:56:29 +03:00
Alexey Golub 7951703cf2 Update version 2019-04-11 01:52:50 +03:00
Alexey Golub 795dfbb213 [CLI] Don't show 'completed' when export failed 2019-04-11 01:36:39 +03:00
Alexey Golub 6d9bc3625f Switch from DateTime to DateTimeOffset 2019-04-11 01:20:52 +03:00
Alexey Golub 4bfb2ec7fd Improve performance (#162) 2019-04-10 23:45:21 +03:00
Oleksii Holub 359278afec Optimize chocolatey package 2019-04-09 18:15:17 +03:00
Oleksii Holub 19f58f2c8c Update readme 2019-04-09 16:32:24 +03:00
Alexey Golub 640b1f5866 Add deploy via chocolatey 2019-04-07 13:01:38 +03:00
Oleksii Holub d13d04e75b Add donation info 2019-04-03 18:33:34 +03:00
Oleksii Holub 6bf0ce3790 Fix dockerfile again 2019-03-25 15:22:51 +02:00
Oleksii Holub aef8a1f6d2 Fix default output directory in dockerfile 2019-03-25 14:35:24 +02:00
Oleksii Holub a63bf95212 Update dockerfile 2019-03-25 11:04:51 +02:00
Oleksii Holub af9855fc15 [CLI] Copy nuget dependencies to output 2019-03-25 11:00:13 +02:00
Alexey Golub 4113101c0c Get rid of ByteSize in favor of our own class 2019-03-24 12:36:25 +02:00
Oleksii Holub cac48b3541 Optimize dockerfile 2019-03-22 15:09:07 +02:00
Alexey Golub 0a60dea5c1 Use net46, netstd2.0, and netcoreapp2.1 for target frameworks
Closes #154
2019-03-22 00:07:13 +02:00
Alexey Golub 889446fff7 Update nuget packages 2019-03-21 22:54:12 +02:00
Alexey Golub 6d1cfef729 Update Onova 2019-03-21 00:55:24 +02:00
Oleksii Holub 77bf32aa5a Update version 2019-03-14 16:36:55 +02:00
Oleksii Holub ac2b450892 Retry on any 5xx HTTP response in DataService
Closes #157
2019-03-14 16:30:27 +02:00
Oleksii Holub 4046fd0ad9 [HTML] Add syntax highlighting via Highlight.js
Closes #138
2019-03-14 16:02:39 +02:00
Alexey Golub 6fa61bf75a Update version 2019-03-10 14:45:39 +02:00
Alexey Golub 1976e3ad08 [CLI] Don't write progress if output is redirected
FIxes #155
2019-03-10 14:40:08 +02:00
Alexey Golub adbe544c57 Add some margin at the end of the chatlog in HTML 2019-03-07 22:15:21 +02:00
Alexey Golub 6f4d1c3d77 Refactor message grouping 2019-03-07 22:13:00 +02:00
Alexey Golub 33f71c1612 Remove message group limit setting 2019-03-07 21:14:11 +02:00
Alexey Golub 9b78472e05 Update message grouping algorithm to match Discord's
Fixes #152
2019-03-07 21:10:22 +02:00
Alexey Golub 1f6d1f21e6 Update version 2019-03-06 23:00:56 +02:00
Alexey Golub 918cb4820f Add alt text to emoji images
Closes #151
2019-03-06 22:57:18 +02:00
Alexey Golub 32af1aa05d Fix incorrect regex for capturing standard emojis
Fixes #147
2019-03-06 22:48:24 +02:00
Alexey Golub dda32d8af2 Fix markdown grammar being too greedy in some places
Fixes #150
2019-03-06 22:37:36 +02:00
Alexey Golub d1d82f975b Add license file to releasable artifacts 2019-03-03 22:26:02 +02:00
Alexey Golub b1de9c4eca Update version 2019-03-03 21:48:03 +02:00
Alexey Golub 48ec4d085a Fix bad overflow wrapping 2019-03-03 21:33:27 +02:00
Alexey Golub 668ac70fae Fix some parsing issues 2019-03-03 21:24:37 +02:00
Alexey Golub 0d1ce38489 Jumboable emojis 2019-03-03 21:09:47 +02:00
Alexey Golub 666f2d8d4a Fix emoji and reaction css 2019-03-03 20:47:24 +02:00
Alexey Golub 2dd0742c1c Fix video attachments getting rendered as images
Fixes #120
2019-03-03 20:20:01 +02:00
Alexey Golub 69fe8af87a Update Failsafe 2019-03-03 20:08:47 +02:00
Alexey Golub e493357a9f Add progress reporting to CLI
Closes #143
2019-03-03 19:59:23 +02:00
Alexey Golub 492003c75a Fix incorrect standard emoji rendering in plaintext 2019-03-03 18:41:54 +02:00
Alexey Golub 28175e2110 Parse emojis in markdown and render them using Twemoji
Closes #140
2019-03-03 18:36:12 +02:00
Alexey Golub a564906719 Use ByteSize for file size in attachments 2019-03-03 16:38:41 +02:00
Alexey Golub 019278f6d4 Fix dockerfile 2019-03-03 16:05:09 +02:00
Alexey Golub 5161593e0f Forgot to commit DataService.Parsers 2019-03-03 15:40:26 +02:00
Alexey Golub 847a05a269 Refactor reaction emojis into a separate class, add support for animations and standard emojis via twemoji
Closes #71
2019-03-03 15:24:31 +02:00
Alexey Golub f09f30c7bd Implement a more sophisticated markdown parsing engine (#145) 2019-03-03 14:16:12 +02:00
Alexey Golub 88727a1fe6 Forgot one more place 2019-02-17 19:55:48 +02:00
Alexey Golub bb98049df7 Remove years from copyright 2019-02-17 19:48:54 +02:00
Alexey Golub e519bdcc6f Update license 2019-02-17 19:31:26 +02:00
Oleksii Holub 1356d4483a Add a checkmark that displays whether a channel is selected 2019-02-15 13:01:30 +02:00
Alexey Golub 4a3dc5c5ee Bind export button visibility on the guard property 2019-02-14 22:29:52 +02:00
Alexey Golub e665356890 Show export progress in taskbar 2019-02-14 21:52:36 +02:00
Alexey Golub 7b614ed448 Update version 2019-02-10 17:27:45 +02:00
Alexey Golub 6531462220 Fix getting last message when there's no date range
Fixes #137
2019-02-10 17:26:18 +02:00
Alexey Golub e89f2735a0 Update version 2019-02-09 21:26:15 +02:00
Alexey Golub 01d276a9dc Use UTF8 encoding in UpdateVersion.ps1 2019-02-09 21:20:20 +02:00
Alexey Golub c446d4069a Export single channel by double-clicking in GUI 2019-02-09 21:05:31 +02:00
Alexey Golub ba5718aaf5 Improve GUI a bit 2019-02-09 20:07:30 +02:00
Alexey Golub 20ff4a15eb Refactor output path usage in CLI 2019-02-09 19:41:08 +02:00
Alexey Golub 991dfccf3c Retry on 503 status code 2019-02-09 19:39:21 +02:00
Alexey Golub 524c86b8f2 Add channel ID to default export file name to avoid collisions 2019-02-09 19:20:13 +02:00
Alexey Golub e4b0d60c40 Add multichannel export to GUI
Closes #12
2019-02-09 19:03:34 +02:00
Alexey Golub 65c5df89f4 Use Failsafe 2019-02-08 20:37:53 +02:00
Oleksii Holub 659c499bd0 Small clenaup 2019-02-01 15:33:06 +02:00
Alexey Golub b6fcbac030 Refactor 2019-01-30 23:14:17 +02:00
Oleksii Holub b3e2dd3994 Group channels by category in GUI
Closes #77
2019-01-30 18:16:46 +02:00
Alexey Golub ba5790e312 Remove reference to Tyrrrz.Settings in GUI project 2019-01-21 23:21:35 +02:00
Alexey Golub 2859a740eb Update nuget packages 2019-01-07 18:29:36 +02:00
Oleksii Holub b88a1f8b47 Some refactor 2018-12-21 15:37:14 +02:00
Alexey Golub 78b44aed19 Use embedded style for dialog host 2018-12-19 21:51:03 +02:00
Oleksii Holub 13925c69af Refactor CLI and add 'exportdm' command to export all direct message channels 2018-12-19 17:29:36 +02:00
Oleksii Holub e22e1c03fb Cleanup 2018-12-19 17:15:54 +02:00
TVdata bf5cedb646 Add CLI command to export all available channels in a guild (#123) 2018-12-19 16:53:21 +02:00
Alexey Golub 17b0161ade Make dialogs white again 2018-12-16 00:07:17 +02:00
Alexey Golub fa1e84b518 Refactor UI 2018-12-15 23:53:52 +02:00
Alexey Golub 6d5ed9e9ca Improve UI 2018-12-14 01:00:57 +02:00
Oleksii Holub bdaeea5512 Refactor busy state and progress reporting 2018-12-11 18:03:32 +02:00
Alexey Golub dddb13fcc5 Refactor default file name generation and add date ranges there
Closes #29
2018-12-10 23:16:15 +02:00
Alexey Golub cacbb58683 Improve usage instructions a bit 2018-12-08 23:56:04 +02:00
Alexey Golub ca727e9f23 Improve naming of exported partition files 2018-12-08 23:55:49 +02:00
Oleksii Holub fd15b62adf Improve UI layout and usage instructions 2018-12-07 18:14:27 +02:00
Oleksii Holub 78d01b47b4 Add some tooltips 2018-12-07 17:05:17 +02:00
Oleksii Holub daf38153f4 Improve UI some more 2018-12-05 18:45:13 +02:00
Oleksii Holub ac91c536dd Improve ExportSetupView UI 2018-12-05 18:20:20 +02:00
Alexey Golub 0dbd619244 Remove unsused library from readme 2018-11-29 20:55:20 +02:00
Alexey Golub 0d3510222e Migrate to Stylet and refactor view/view-model framework 2018-11-29 19:18:44 +02:00
Alexey Golub 083bdef419 Move screenshots section below features section in readme 2018-11-17 23:22:25 +02:00
Oleksii Holub d620010e85 Add some safeguarding in case partition limit is less or equal to zero 2018-11-15 10:26:54 +02:00
Oleksii Holub beadc25e61 Cleanup GroupMessages 2018-11-14 18:15:07 +02:00
Oleksii Holub 8bc29c4928 Refactor SplitIntoPartitions 2018-11-14 17:51:03 +02:00
Oleksii Holub 0b14952346 Add a script to update version in all projects 2018-11-05 13:31:42 +02:00
Alexey Golub b518fb2f59 Update version 2018-11-03 23:51:19 +02:00
Alexey Golub 94165dcd8d Don't render the template in memory to avoid out of memory exceptions
Fixes #88
2018-11-03 23:04:26 +02:00
Alexey Golub d6507598fa Fix not being able to reset partition limit in GUI 2018-11-03 23:01:58 +02:00
Alexey Golub a0359b1e43 Split output file into multiple partitions (#116) 2018-11-03 22:53:20 +02:00
Oleksii Holub aa53cecd4e Don't open exported file on completion
Fixes #85
2018-11-02 17:12:17 +02:00
Oleksii Holub 4144911208 Remove autoupdate feature from CLI entirely 2018-11-02 16:24:06 +02:00
Oleksii Holub d28e81f8dc Don't replace new lines in csv
Part of #102
2018-11-01 18:33:29 +02:00
Oleksii Holub 761cb032d7 Don't group messages in PlainText export
Part of #102
2018-11-01 18:27:41 +02:00
Oleksii Holub 47f0561c71 Refactor message grouping from data layer to render layer 2018-11-01 18:16:23 +02:00
Oleksii Holub 23116b776b Show unhandled exceptions to users 2018-11-01 14:43:21 +02:00
Oleksii Holub ba27a9f909 Rename ExportService.Export to ExportChatLog for consistency 2018-10-31 17:54:13 +02:00
Oleksii Holub 52684c264f Change order of parameters in ExportService.Export 2018-10-31 17:35:28 +02:00
Oleksii Holub bcad5b4ed1 Set ChatLog.TotalMessageCount from ChatLogService 2018-10-31 17:34:57 +02:00
Alexey Golub 95a4217ab3 Refactor resolving chat log to a separate service to allow reusability across CLI and GUI 2018-10-30 22:52:28 +02:00
Alexey Golub 4e8fb80ac5 Use PackageReference 2018-10-28 15:25:22 +02:00
Alexey Golub 7ae560dde0 Fix changelog 2018-09-14 00:36:31 +03:00
Alexey Golub 97a36028b9 Update version 2018-09-14 00:34:18 +03:00
Alexey Golub f0d99676a3 Disable UpdateAppOptions in CLI 2018-09-14 00:25:21 +03:00
Alexey Golub 8e38adae7e Update instructions to obtain token
Fixes #76
2018-09-14 00:23:23 +03:00
Alexey Golub d95bc37592 Link to dockerhub from readme 2018-09-13 23:32:09 +03:00
Hyeon Kim 3f6354053c Dockerize the application (#92) 2018-09-13 22:50:15 +03:00
Alexey Golub a9bab60ba6 Update instructions to obtain token 2018-08-14 00:49:32 +03:00
Alexey Golub 614bd8590d Generate file name if given output path CLI argument is a directory
Closes #67
2018-08-13 23:25:06 +03:00
Alexey Golub bd9dc6455f Refactor CLI (#81) 2018-08-13 22:49:13 +03:00
Alexey Golub 0faa427970 Invert if condition in authentication header 2018-08-03 00:13:44 +03:00
Alexey Golub dd9da4831e Change how user token is sent in requests 2018-08-03 00:00:51 +03:00
Oleksii Holub 0d816cee5d Update readme 2018-07-26 18:25:47 +03:00
Oleksii Holub f63ea41188 Update version 2018-07-25 11:53:05 +03:00
Alexey Golub 3572a21aad Allow using bot token instead of user token (#70) 2018-07-24 22:47:45 +03:00
Alexey Golub 37ee0b8be3 Cleanup templates and change how model is referenced 2018-07-03 22:58:46 +03:00
trainboy2019 ed146bac22 Add support for animated avatars (#66) 2018-07-02 21:12:32 +03:00
Alexey Golub b9c449709f Update version 2018-07-01 21:12:06 +03:00
Alexey Golub 25207c5093 Improve the templates a bit 2018-07-01 21:04:03 +03:00
Alexey Golub 4fbec95208 Fix CSV and PlainText outputs not rendering timestamps correctly
Fixes #65
2018-07-01 16:53:33 +03:00
Alexey Golub 0a48da4c56 Update version 2018-06-28 00:36:26 +03:00
Alexey Golub 3f5f62585c Make emojis larger if they are the only thing in content 2018-06-28 00:29:47 +03:00
Alexey Golub 49f39c7097 Add retry policy for 429 responses 2018-06-28 00:20:31 +03:00
Alexey Golub e3eac10fb8 Bring HTML light theme in line with dark theme 2018-06-27 23:16:28 +03:00
Alexey Golub 23f9331e4e Cleanup 2018-06-27 00:45:29 +03:00
Alexey Golub 69088b83eb Html-encode date ranges in html export 2018-06-26 23:23:18 +03:00
Alexey Golub 7ecf90d1cb Refactor css for 'pre' with modifiers 2018-06-26 01:14:54 +03:00
Alexey Golub f0cd2dce1b Improve reactions 2018-06-26 01:09:23 +03:00
IntriguingTiles e8436faf66 Add support for reactions (#62) 2018-06-26 00:27:45 +03:00
Alexey Golub 61dce7c1a8 Small cleanup 2018-06-25 23:48:40 +03:00
Alexey Golub 6fee160bc7 Show an error when trying to export deleted channel
Closes #51
2018-06-25 23:34:29 +03:00
Alexey Golub 8678043f0d Implement progress reporting when downloading messages (#57) 2018-06-25 23:11:34 +03:00
Oleksii Holub e4f0b8193f Output date range in the export file
Closes #56
2018-06-25 18:44:50 +03:00
Oleksii Holub df811d0b1a Add loading placeholder for guild icons 2018-06-25 17:38:28 +03:00
Oleksii Holub 08da5d685b Prevent choosing invalid from/to dates 2018-06-25 17:11:52 +03:00
Alexey Golub c2cebe99e3 Fix wrong json node being used to parse mentioned users in messages 2018-06-25 02:28:38 +03:00
Alexey Golub 481991bd00 Architecture refactor (#63) 2018-06-25 01:59:15 +03:00
Malcolm Diller d958f613a3 Add support for embeds (#46) 2018-05-06 13:09:25 +03:00
Alexey Golub 3b7da21c24 Update version 2018-04-15 17:19:46 +03:00
Alexey Golub 1d0a13c613 Always open exported file on completion 2018-04-15 17:13:26 +03:00
Alexey Golub 67036f59ce Order channels by name in the UI 2018-04-15 17:11:45 +03:00
Alexey Golub 517542730a Print available export formats in CLI help 2018-04-15 17:03:48 +03:00
Alexey Golub 19dc2334e2 Update readme 2018-04-15 17:01:22 +03:00
Alexey Golub 4e1f783145 Update readme 2018-04-15 16:59:39 +03:00
Alexey Golub ff6ef480ab Remove comments from csv exporter 2018-04-15 16:58:47 +03:00
Tim van den Essen e1e8c73613 Add csv exporting (#44) 2018-04-15 16:52:02 +03:00
Alexey Golub e0284e281c Update readme 2018-04-15 16:26:53 +03:00
Alexey Golub 435510baf9 Remove AmmyUI 2018-04-15 16:08:57 +03:00
166 changed files with 6423 additions and 3258 deletions
+1 -4
View File
@@ -258,7 +258,4 @@ paket-files/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Ammy auto-generated XAML
*.g.xaml
*.pyc
+124
View File
@@ -1,3 +1,127 @@
### v2.14 (15-Jun-2019)
- [TXT] Added support for embeds.
- [TXT] Added support for reactions.
- [CSV] Added support for reactions.
- [TXT] Changed how attachments are rendered.
### v2.13.1 (06-Jun-2019)
- Fixed an issue where the app sometimes crashed when exporting due to `System.InvalidCastException`.
- [HTML] Added `data-user-id` attribute to `span.chatlog__author-name`. The value of this attribute is author's Discord user ID.
### v2.13 (15-May-2019)
- Updated usage instructions.
- [HTML] Added BOT tags to messages posted by bots.
- [GUI] UI will now be disabled when exporting.
- [GUI] Notification informing that the export has completed will now appear after all exports have completed, instead of each of them separately.
### v2.12.1 (12-Apr-2019)
- [GUI] Fixed an issue where the app crashed when trying to export multiple channels.
- [HTML] Fixed an issue where some of the emojis were rendered via Twemoji while Discord renders them as plain text.
- [HTML] Fixed an issue where emojis weren't jumbo if there were more than one of them.
### v2.12 (11-Apr-2019)
- Improved markdown parsing performance which speeds up the final stage of exporting by around 4.5 times.
- Improved performance when exporting as PlainText or CSV.
- [GUI] Fixed an issue where the app would freeze up and report as "not responding" during the final stage of exporting.
- [CLI] Fixed an issue where it would say "Completed" after exporting a chat log, even if the process failed.
### v2.11 (14-Mar-2019)
- [HTML] Added syntax highlighting for multiline code blocks via Highlight.js.
- Added retry policy for all 5xx status codes to prevent random crashes.
### v2.10.2 (10-Mar-2019)
- [HTML] Updated message grouping algorithm to make it the same as in Discord. Removed "message group limit" setting and parameter.
- [HTML] Added small margin at the end of the chatlog so it doesn't look like it was truncated.
- [CLI] Fixed an issue where the app would crash if stdout was redirected. Progress will not be reported in such cases.
### v2.10.1 (06-Mar-2019)
- [HTML] Fixed an issue where multiple emojis on a single line would get rendered as one emoji.
- [HTML] Fixed an issue where an open square bracket character would incorrectly render as an emoji.
- [HTML] Added alt text to emoji images so that they will be rendered using default font when Twemoji images are not available.
### v2.10 (03-Mar-2019)
- [HTML] Reworked the entire markdown parsing engine which fixes numerous rendering inconsistencies (over 15 issues fixed).
- [HTML] Added support for animated emojis.
- [HTML] Standard emojis are now rendered using Twemoji, the same image set that Discord uses.
- [HTML] Fixed an issue where video attachments would render incorrectly.
- [HTML] Minor fixes in styling.
- [GUI] Aggregated export progress is now shown in the taskbar.
- [GUI] Checkmarks are now displayed next to channels that are selected.
- [CLI] Added progress reporting when exporting chat logs.
### v2.9.1 (10-Feb-2019)
- Fixed an issue where some of the last messages in a channel would sometimes not get exported.
### v2.9 (09-Feb-2019)
- Added categories to channel list in GUI. Channels are now also grouped by category.
- Added ability to export multiple channels at once in GUI by selecting them with shift or ctrl keys.
- Added CLI command to export all available channels in a guild (`exportguild`).
- Added CLI command to export all direct message channels (`exportdm`).
- Added retry policy for 503 status code errors to prevent random crashes.
- Changed how channels are exported in GUI. Now you need to select them first and then click a button to export. You can also double click a single channel to export it as before.
- Improved default file name generation by adding date ranges and channel ID to the file name.
- Improved naming of files exported with partitioning.
- Improved usage instructions.
- Improved GUI layout and styling.
- Fixed an issue where dialogs would sometimes appear over other windows.
- Fixed an issue where launching DiscordChatExporter from Windows Start Menu would make it crash.
### v2.8 (03-Nov-2018)
- Added support for partitioning which lets you split the output into multiple files by setting the partition message limit (`-p` parameter for CLI).
- Exported file will no longer open automatically on completion.
- Reduced amount of memory used during exporting.
- Disabled message grouping in PlainText export.
- Improved encoding of newlines in CSV export.
- Improved error messages in the GUI app during crashes.
### v2.7 (14-Sep-2018)
- Updated instructions on how to obtain the user token.
- Expanded CLI with new commands: `channels` (get a list of channels in a guild), `dm` (get a list of DM channels), `guilds` (get a list of guilds), on top of `export` (export chatlog).
- Improved help text and error messages in CLI.
- In CLI, the file name will be automatically generated if the provided output file path is a directory.
### v2.6 (25-Jul-2018)
- Added support for bot tokens as an alternative to user tokens. For GUI, use the button in the top-left to switch between user and bot token. For CLI, pass the `--bot` switch to indicate that the given token is a bot token.
- Added support for animated avatars.
### v2.5.1 (01-Jul-2018)
- Fixed a bug that would prevent timestamps from rendering properly in CSV and PlainText exports.
- Fixed incorrect layout in CSV export.
- Improved readability of PlainText export.
### v2.5 (28-Jun-2018)
- Added support for embeds and webhooks (HTML only).
- Implemented progress reporting when downloading messages.
- Added support for reactions (HTML only).
- Selected date range is now shown in the export (HTML and PlainText).
- Added placeholders for guild icons to display while the icons are loading.
- Added support for jumbo emojis (custom emojis only).
- Added basic retry policy for 429 error responses.
- Added validation for from/to dates in the UI which prevents choosing invalid date ranges.
- Added an error message when trying to export a deleted channel.
### v2.4.1 (15-Apr-2018)
- Added CSV export format.
- Channels are now ordered by name in the UI.
- Exported file is now always opened upon completion.
### v2.4 (08-Apr-2018)
- Added channel topic to output.
+16 -6
View File
@@ -1,11 +1,21 @@
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
New-Item "$PSScriptRoot\Portable\bin" -ItemType Directory -Force
# GUI
# --- GUI ---
# Get files
$files = @()
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
# CLI
# Pack into archive
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.zip" -Force
# --- CLI ---
# Get files
$files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.Cli.zip" -Force
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net46\*" -Include "*.exe", "*.dll", "*.config"
# Pack into archive
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.CLI.zip" -Force
-24
View File
@@ -1,24 +0,0 @@
using System;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli
{
public class CliOptions
{
public string Token { get; set; }
public string ChannelId { get; set; }
public ExportFormat ExportFormat { get; set; }
public string FilePath { get; set; }
public DateTime? From { get; set; }
public DateTime? To { get; set; }
public string DateFormat { get; set; }
public int MessageGroupLimit { get; set; }
}
}
+12 -25
View File
@@ -1,37 +1,24 @@
using CommonServiceLocator;
using DiscordChatExporter.Cli.ViewModels;
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight.Ioc;
using DiscordChatExporter.Core.Services;
using StyletIoC;
namespace DiscordChatExporter.Cli
{
public class Container
public static class Container
{
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
public ISettingsService SettingsService => Resolve<ISettingsService>();
public static IContainer Instance { get; }
private T Resolve<T>(string key = null)
static Container()
{
return ServiceLocator.Current.GetInstance<T>(key);
}
var builder = new StyletIoCBuilder();
public void Init()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Autobind the .Services assembly
builder.Autobind(typeof(DataService).Assembly);
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// View models
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
}
public void Cleanup()
{
// Set instance
Instance = builder.BuildContainer();
}
}
}
@@ -1,23 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<Version>2.4</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
<Version>2.14</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
<PackageReference Include="FluentCommandLineParser" Version="1.4.3" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.3.0" />
<PackageReference Include="Stylet" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
<ProjectReference Include="..\DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using System;
namespace DiscordChatExporter.Cli.Internal
{
internal class InlineProgress : IProgress<double>, IDisposable
{
private readonly int _posX;
private readonly int _posY;
private bool _isCompleted;
public InlineProgress()
{
// If output is not redirected - save initial cursor position
if (!Console.IsOutputRedirected)
{
_posX = Console.CursorLeft;
_posY = Console.CursorTop;
}
}
public void Report(double progress)
{
// If output is not redirected - reset cursor position and write progress
if (!Console.IsOutputRedirected)
{
Console.SetCursorPosition(_posX, _posY);
Console.WriteLine($"{progress:P1}");
}
}
public void ReportCompletion() => _isCompleted = true;
public void Dispose()
{
// If output is not redirected - reset cursor position
if (!Console.IsOutputRedirected)
Console.SetCursorPosition(_posX, _posY);
// Inform about completion
if (_isCompleted)
Console.WriteLine("Completed ✓");
else
Console.WriteLine("Failed X");
}
}
}
+58 -85
View File
@@ -1,107 +1,80 @@
using System;
using System.Reflection;
using DiscordChatExporter.Core.Models;
using Fclp;
using System.Linq;
using CommandLine;
using DiscordChatExporter.Cli.Verbs;
using DiscordChatExporter.Cli.Verbs.Options;
namespace DiscordChatExporter.Cli
{
public static class Program
{
private static readonly Container Container = new Container();
private static void ShowHelp()
private static void PrintTokenHelp()
{
var version = Assembly.GetExecutingAssembly().GetName().Version;
Console.WriteLine($"=== Discord Chat Exporter (Command Line Interface) v{version} ===");
Console.WriteLine("# To get user token:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 3. Navigate to the Application tab");
Console.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 5. Press Ctrl+R to reload");
Console.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
Console.WriteLine();
Console.WriteLine("[-t] [--token] Discord authorization token.");
Console.WriteLine("[-c] [--channel] Discord channel ID.");
Console.WriteLine("[-f] [--format] Export format (PlainText/HtmlDark/HtmlLight). Optional.");
Console.WriteLine("[-o] [--output] Output file path. Optional.");
Console.WriteLine(" [--datefrom] Limit to messages after this date. Optional.");
Console.WriteLine(" [--dateto] Limit to messages before this date. Optional.");
Console.WriteLine(" [--dateformat] Date format. Optional.");
Console.WriteLine(" [--grouplimit] Message group limit. Optional.");
Console.WriteLine("# To get bot token:");
Console.WriteLine(" 1. Go to Discord developer portal");
Console.WriteLine(" 2. Open your application's settings");
Console.WriteLine(" 3. Navigate to the Bot section on the left");
Console.WriteLine(" 4. Under Token click Copy");
Console.WriteLine();
Console.WriteLine("# To get authorization token:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Application tab");
Console.WriteLine(" - Expand Storage > Local Storage > https://discordapp.com");
Console.WriteLine(" - Find \"token\" under key and copy the value");
Console.WriteLine("# To get guild ID or guild channel ID:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open Settings");
Console.WriteLine(" 3. Go to Appearance section");
Console.WriteLine(" 4. Enable Developer Mode");
Console.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
Console.WriteLine();
Console.WriteLine("# To get channel ID:");
Console.WriteLine(" - Open Discord app");
Console.WriteLine(" - Log in if you haven't");
Console.WriteLine(" - Go to any channel you want to export");
Console.WriteLine(" - Press Ctrl+Shift+I");
Console.WriteLine(" - Navigate to Console tab");
Console.WriteLine(" - Type \"document.URL\" and press Enter");
Console.WriteLine(" - Copy the long sequence of numbers after last slash");
}
private static CliOptions ParseOptions(string[] args)
{
var argsParser = new FluentCommandLineParser<CliOptions>();
var settings = Container.SettingsService;
argsParser.Setup(o => o.Token).As('t', "token").Required();
argsParser.Setup(o => o.ChannelId).As('c', "channel").Required();
argsParser.Setup(o => o.ExportFormat).As('f', "format").SetDefault(ExportFormat.HtmlDark);
argsParser.Setup(o => o.FilePath).As('o', "output").SetDefault(null);
argsParser.Setup(o => o.From).As("datefrom").SetDefault(null);
argsParser.Setup(o => o.To).As("dateto").SetDefault(null);
argsParser.Setup(o => o.DateFormat).As("dateformat").SetDefault(settings.DateFormat);
argsParser.Setup(o => o.MessageGroupLimit).As("grouplimit").SetDefault(settings.MessageGroupLimit);
var parsed = argsParser.Parse(args);
// Show help if no arguments
if (parsed.EmptyArgs)
{
ShowHelp();
Environment.Exit(0);
}
// Show error if there are any
else if (parsed.HasErrors)
{
Console.Error.Write(parsed.ErrorText);
Environment.Exit(-1);
}
return argsParser.Object;
Console.WriteLine("# To get direct message channel ID:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open the desired direct message channel");
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 4. Navigate to the Console tab");
Console.WriteLine(" 5. Type \"window.location.href\" and press Enter");
Console.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
}
public static void Main(string[] args)
{
// Init container
Container.Init();
// Get all verb types
var verbTypes = new[]
{
typeof(ExportChannelOptions),
typeof(ExportDirectMessagesOptions),
typeof(ExportGuildOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions)
};
// Parse options
var options = ParseOptions(args);
// Parse command line arguments
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
// Inject some settings
var settings = Container.SettingsService;
settings.DateFormat = options.DateFormat;
settings.MessageGroupLimit = options.MessageGroupLimit;
// Execute commands
parsedArgs.WithParsed<ExportChannelOptions>(o => new ExportChannelVerb(o).Execute());
parsedArgs.WithParsed<ExportDirectMessagesOptions>(o => new ExportDirectMessagesVerb(o).Execute());
parsedArgs.WithParsed<ExportGuildOptions>(o => new ExportGuildVerb(o).Execute());
parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
// Export
var vm = Container.MainViewModel;
vm.ExportAsync(
options.Token,
options.ChannelId,
options.FilePath,
options.ExportFormat,
options.From,
options.To).GetAwaiter().GetResult();
// Show token help if help requested or no verb specified
parsedArgs.WithNotParsed(errs =>
{
var err = errs.First();
// Cleanup container
Container.Cleanup();
if (err.Tag == ErrorType.NoVerbSelectedError)
PrintTokenHelp();
Console.WriteLine("Export complete.");
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
PrintTokenHelp();
});
}
}
}
@@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportChannelVerb : Verb<ExportChannelOptions>
{
public ExportChannelVerb(ExportChannelOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Track progress
Console.Write($"Exporting channel [{Options.ChannelId}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
Options.After, Options.Before, progress);
// Generate file path if not set or is a directory
var filePath = Options.OutputPath;
if (filePath.IsNullOrWhiteSpace() || ExportHelper.IsDirectoryPath(filePath))
{
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Combine paths
filePath = Path.Combine(filePath ?? "", fileName);
}
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
}
}
@@ -0,0 +1,78 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportDirectMessagesVerb : Verb<ExportDirectMessagesOptions>
{
public ExportDirectMessagesVerb(ExportDirectMessagesOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportGuildVerb : Verb<ExportGuildOptions>
{
public ExportGuildVerb(ExportGuildOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -0,0 +1,33 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetChannelsVerb : Verb<GetChannelsOptions>
{
public GetChannelsVerb(GetChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,32 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
{
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,32 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetGuildsVerb : Verb<GetGuildsOptions>
{
public GetGuildsVerb(GetGuildsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var dataService = Container.Instance.Get<DataService>();
// Get guilds
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
// Order guilds
guilds = guilds.OrderBy(g => g.Name).ToArray();
// Print result
foreach (var guild in guilds)
Console.WriteLine($"{guild.Id} | {guild.Name}");
}
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("export", HelpText = "Export channel.")]
public class ExportChannelOptions : ExportOptions
{
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
public string ChannelId { get; set; }
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportdm", HelpText = "Export all direct message channels.")]
public class ExportDirectMessagesOptions : ExportOptions
{
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportguild", HelpText = "Export all channels within a given guild.")]
public class ExportGuildOptions : ExportOptions
{
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
public string GuildId { get; set; }
}
}
@@ -0,0 +1,29 @@
using System;
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public abstract class ExportOptions : TokenOptions
{
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
public ExportFormat ExportFormat { get; set; }
[Option('o', "output", Default = null, HelpText = "Output file or directory path.")]
public string OutputPath { get; set; }
// HACK: CommandLineParser doesn't support DateTimeOffset
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
public DateTime? After { get; set; }
// HACK: CommandLineParser doesn't support DateTimeOffset
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
public DateTime? Before { get; set; }
[Option('p', "partition", Default = null, HelpText = "Split output into partitions limited to this number of messages.")]
public int? PartitionLimit { get; set; }
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
public string DateFormat { get; set; }
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("channels", HelpText = "Get the list of channels in the given guild.")]
public class GetChannelsOptions : TokenOptions
{
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
public string GuildId { get; set; }
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("dm", HelpText = "Get the list of direct message channels.")]
public class GetDirectMessageChannelsOptions : TokenOptions
{
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
public class GetGuildsOptions : TokenOptions
{
}
}
@@ -0,0 +1,16 @@
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public abstract class TokenOptions
{
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
public string TokenValue { get; set; }
[Option('b', "bot", Default = false, HelpText = "Whether this authorization token belongs to a bot.")]
public bool IsBotToken { get; set; }
public AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
}
}
+18
View File
@@ -0,0 +1,18 @@
using System.Threading.Tasks;
namespace DiscordChatExporter.Cli.Verbs
{
public abstract class Verb<TOptions>
{
protected TOptions Options { get; }
protected Verb(TOptions options)
{
Options = options;
}
public abstract Task ExecuteAsync();
public virtual void Execute() => ExecuteAsync().GetAwaiter().GetResult();
}
}
@@ -1,12 +0,0 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.ViewModels
{
public interface IMainViewModel
{
Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
DateTime? to);
}
}
@@ -1,53 +0,0 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.ViewModels
{
public class MainViewModel : IMainViewModel
{
private readonly IDataService _dataService;
private readonly IMessageGroupService _messageGroupService;
private readonly IExportService _exportService;
public MainViewModel(IDataService dataService, IMessageGroupService messageGroupService,
IExportService exportService)
{
_dataService = dataService;
_messageGroupService = messageGroupService;
_exportService = exportService;
}
public async Task ExportAsync(string token, string channelId, string filePath, ExportFormat format, DateTime? from,
DateTime? to)
{
// Get channel and guild
var channel = await _dataService.GetChannelAsync(token, channelId);
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await _dataService.GetGuildAsync(token, channel.GuildId);
// Generate file path if not set
if (filePath.IsBlank())
{
filePath = $"{guild.Name} - {channel.Name}.{format.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
}
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(token, channelId, from, to);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var log = new ChannelChatLog(guild, channel, messageGroups, messages.Count);
// Export
await _exportService.ExportAsync(format, filePath, log);
}
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,46 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class AggregateMatcher<T> : IMatcher<T>
{
private readonly IReadOnlyList<IMatcher<T>> _matchers;
public AggregateMatcher(IReadOnlyList<IMatcher<T>> matchers)
{
_matchers = matchers;
}
public AggregateMatcher(params IMatcher<T>[] matchers)
: this((IReadOnlyList<IMatcher<T>>)matchers)
{
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
{
ParsedMatch<T> earliestMatch = null;
// Try to match the input with each matcher and get the match with the lowest start index
foreach (var matcher in _matchers)
{
// Try to match
var match = matcher.Match(input, startIndex, length);
// If there's no match - continue
if (match == null)
continue;
// If this match is earlier than previous earliest - replace
if (earliestMatch == null || match.StartIndex < earliestMatch.StartIndex)
earliestMatch = match;
// If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that
if (earliestMatch.StartIndex == startIndex)
break;
}
return earliestMatch;
}
}
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal static class Extensions
{
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
int startIndex, int length, Func<string, T> fallbackTransform)
{
// Get end index for simplicity
var endIndex = startIndex + length;
// Loop through segments divided by individual matches
var currentIndex = startIndex;
while (currentIndex < endIndex)
{
// Find a match within this segment
var match = matcher.Match(input, currentIndex, endIndex - currentIndex);
// If there's no match - break
if (match == null)
break;
// If this match doesn't start immediately at current index - transform and yield fallback first
if (match.StartIndex > currentIndex)
{
var fallback = input.Substring(currentIndex, match.StartIndex - currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
}
// Yield match
yield return match;
// Shift current index to the end of the match
currentIndex = match.StartIndex + match.Length;
}
// If EOL wasn't reached - transform and yield remaining part as fallback
if (currentIndex < endIndex)
{
var fallback = input.Substring(currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
}
}
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
Func<string, T> fallbackTransform) => matcher.MatchAll(input, 0, input.Length, fallbackTransform);
}
}
@@ -0,0 +1,7 @@
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal interface IMatcher<T>
{
ParsedMatch<T> Match(string input, int startIndex, int length);
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class ParsedMatch<T>
{
public int StartIndex { get; }
public int Length { get; }
public T Value { get; }
public ParsedMatch(int startIndex, int length, T value)
{
StartIndex = startIndex;
Length = length;
Value = value;
}
}
}
@@ -0,0 +1,23 @@
using System;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class RegexMatcher<T> : IMatcher<T>
{
private readonly Regex _regex;
private readonly Func<Match, T> _transform;
public RegexMatcher(Regex regex, Func<Match, T> transform)
{
_regex = regex;
_transform = transform;
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
{
var match = _regex.Match(input, startIndex, length);
return match.Success ? new ParsedMatch<T>(match.Index, match.Length, _transform(match)) : null;
}
}
}
@@ -0,0 +1,29 @@
using System;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class StringMatcher<T> : IMatcher<T>
{
private readonly string _needle;
private readonly StringComparison _comparison;
private readonly Func<string, T> _transform;
public StringMatcher(string needle, StringComparison comparison, Func<string, T> transform)
{
_needle = needle;
_comparison = comparison;
_transform = transform;
}
public StringMatcher(string needle, Func<string, T> transform)
: this(needle, StringComparison.Ordinal, transform)
{
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
{
var index = input.IndexOf(_needle, startIndex, length, _comparison);
return index >= 0 ? new ParsedMatch<T>(index, _needle.Length, _transform(_needle)) : null;
}
}
}
@@ -0,0 +1,205 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Markdown.Internal;
using DiscordChatExporter.Core.Markdown.Nodes;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Markdown
{
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
public static class MarkdownParser
{
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant;
/* Formatting */
// Capture any character until the earliest double asterisk not followed by an asterisk
private static readonly IMatcher<Node> BoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "**", TextFormatting.Bold, Parse(m.Groups[1].Value)));
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
// Opening asterisk must not be followed by whitespace
// Closing asterisk must not be preceded by whitespace
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value)));
// Capture any character until the earliest triple asterisk not followed by an asterisk
private static readonly IMatcher<Node> ItalicBoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value, BoldFormattedNodeMatcher)));
// Capture any character except underscore until an underscore
// Closing underscore must not be followed by a word character
private static readonly IMatcher<Node> ItalicAltFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value)));
// Capture any character until the earliest double underscore not followed by an underscore
private static readonly IMatcher<Node> UnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "__", TextFormatting.Underline, Parse(m.Groups[1].Value)));
// Capture any character until the earliest triple underscore not followed by an underscore
private static readonly IMatcher<Node> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value, UnderlineFormattedNodeMatcher)));
// Capture any character until the earliest double tilde
private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "~~", TextFormatting.Strikethrough, Parse(m.Groups[1].Value)));
// Capture any character until the earliest double pipe
private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, Parse(m.Groups[1].Value)));
/* Code blocks */
// Capture any character except backtick until a backtick
// Whitespace surrounding content inside backticks is trimmed
private static readonly IMatcher<Node> InlineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline),
m => new InlineCodeBlockNode(m.Value, m.Groups[1].Value.Trim()));
// Capture language identifier and then any character until the earliest triple backtick
// Languge identifier is one word immediately after opening backticks, followed immediately by newline
// Whitespace surrounding content inside backticks is trimmed
private static readonly IMatcher<Node> MultilineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value.Trim()));
/* Mentions */
// Capture @everyone
private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>(
"@everyone",
s => new MentionNode(s, "everyone", MentionType.Meta));
// Capture @here
private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>(
"@here",
s => new MentionNode(s, "here", MentionType.Meta));
// Capture <@123456> or <@!123456>
private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.User));
// Capture <#123456>
private static readonly IMatcher<Node> ChannelMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<#(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Channel));
// Capture <@&123456>
private static readonly IMatcher<Node> RoleMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@&(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Role));
/* Emojis */
// Capture any country flag emoji (two regional indicator surrogate pairs)
// ... or "symbol/other" character
// ... or surrogate pair
// ... or digit followed by enclosing mark
// (this does not match all emojis in Discord but it's reasonably accurate enough)
private static readonly IMatcher<Node> StandardEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|\\p{So}|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
m => new EmojiNode(m.Value, m.Groups[1].Value));
// Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsNullOrWhiteSpace()));
/* Links */
// Capture [title](link)
private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[2].Value, m.Groups[1].Value));
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
private static readonly IMatcher<Node> AutoLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[1].Value));
// Same as auto link but also surrounded by angular brackets
private static readonly IMatcher<Node> HiddenLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[1].Value));
/* Text */
// Capture the shrug emoticon
// This escapes it from matching for formatting
private static readonly IMatcher<Node> ShrugTextNodeMatcher = new StringMatcher<Node>(
@"¯\_(ツ)_/¯",
s => new TextNode(s));
// Capture some specific emojis that don't get rendered
// This escapes it from matching for emoji
private static readonly IMatcher<Node> IgnoredEmojiTextNodeMatcher = new RegexMatcher<Node>(
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
// This escapes it from matching for emoji
private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
// This escapes it from matching for formatting or other tokens
private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
// Combine all matchers into one
// Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<Node> AggregateNodeMatcher = new AggregateMatcher<Node>(
// Escaped text
ShrugTextNodeMatcher,
IgnoredEmojiTextNodeMatcher,
EscapedSymbolTextNodeMatcher,
EscapedCharacterTextNodeMatcher,
// Formatting
ItalicBoldFormattedNodeMatcher,
ItalicUnderlineFormattedNodeMatcher,
BoldFormattedNodeMatcher,
ItalicFormattedNodeMatcher,
UnderlineFormattedNodeMatcher,
ItalicAltFormattedNodeMatcher,
StrikethroughFormattedNodeMatcher,
SpoilerFormattedNodeMatcher,
// Code blocks
MultilineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher,
// Mentions
EveryoneMentionNodeMatcher,
HereMentionNodeMatcher,
UserMentionNodeMatcher,
ChannelMentionNodeMatcher,
RoleMentionNodeMatcher,
// Links
TitledLinkNodeMatcher,
AutoLinkNodeMatcher,
HiddenLinkNodeMatcher,
// Emoji
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher);
private static IReadOnlyList<Node> Parse(string input, IMatcher<Node> matcher) =>
matcher.MatchAll(input, s => new TextNode(s)).Select(r => r.Value).ToArray();
public static IReadOnlyList<Node> Parse(string input) => Parse(input, AggregateNodeMatcher);
}
}
@@ -0,0 +1,30 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class EmojiNode : Node
{
public string Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public bool IsCustomEmoji => !Id.IsNullOrWhiteSpace();
public EmojiNode(string source, string id, string name, bool isAnimated)
: base(source)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
}
public EmojiNode(string source, string name)
: this(source, null, name, false)
{
}
public override string ToString() => $"<Emoji> {Name}";
}
}
@@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class FormattedNode : Node
{
public string Token { get; }
public TextFormatting Formatting { get; }
public IReadOnlyList<Node> Children { get; }
public FormattedNode(string source, string token, TextFormatting formatting, IReadOnlyList<Node> children)
: base(source)
{
Token = token;
Formatting = formatting;
Children = children;
}
public override string ToString() => $"<{Formatting}> ({Children.Count} direct children)";
}
}
@@ -0,0 +1,15 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class InlineCodeBlockNode : Node
{
public string Code { get; }
public InlineCodeBlockNode(string source, string code)
: base(source)
{
Code = code;
}
public override string ToString() => $"<Code> {Code}";
}
}
@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class LinkNode : Node
{
public string Url { get; }
public string Title { get; }
public LinkNode(string source, string url, string title)
: base(source)
{
Url = url;
Title = title;
}
public LinkNode(string source, string url) : this(source, url, url)
{
}
public override string ToString() => $"<Link> {Title}";
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class MentionNode : Node
{
public string Id { get; }
public MentionType Type { get; }
public MentionNode(string source, string id, MentionType type)
: base(source)
{
Id = id;
Type = type;
}
public override string ToString() => $"<{Type} mention> {Id}";
}
}
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public enum MentionType
{
Meta,
User,
Channel,
Role
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class MultilineCodeBlockNode : Node
{
public string Language { get; }
public string Code { get; }
public MultilineCodeBlockNode(string source, string language, string code)
: base(source)
{
Language = language;
Code = code;
}
public override string ToString() => $"<Code [{Language}]> {Code}";
}
}
@@ -0,0 +1,12 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public abstract class Node
{
public string Source { get; }
protected Node(string source)
{
Source = source;
}
}
}
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public enum TextFormatting
{
Bold,
Italic,
Underline,
Strikethrough,
Spoiler
}
}
@@ -0,0 +1,19 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class TextNode : Node
{
public string Text { get; }
public TextNode(string source, string text)
: base(source)
{
Text = text;
}
public TextNode(string text) : this(text, text)
{
}
public override string ToString() => Text;
}
}
@@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#attachment-object
public partial class Attachment
{
public string Id { get; }
public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public string FileName { get; }
public bool IsImage { get; }
public FileSize FileSize { get; }
public Attachment(string id, int? width, int? height, string url, string fileName, FileSize fileSize)
{
Id = id;
Url = url;
Width = width;
Height = height;
FileName = fileName;
FileSize = fileSize;
IsImage = GetIsImage(fileName);
}
public override string ToString() => FileName;
}
public partial class Attachment
{
private static readonly string[] ImageFileExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".bmp" };
public static bool GetIsImage(string fileName)
{
var fileExtension = Path.GetExtension(fileName);
return ImageFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase);
}
}
}
@@ -0,0 +1,15 @@
namespace DiscordChatExporter.Core.Models
{
public class AuthToken
{
public AuthTokenType Type { get; }
public string Value { get; }
public AuthToken(AuthTokenType type, string value)
{
Type = type;
Value = value;
}
}
}
@@ -1,8 +1,8 @@
namespace DiscordChatExporter.Core.Models
{
public enum AttachmentType
public enum AuthTokenType
{
Other,
Image
User,
Bot
}
}
@@ -1,9 +1,13 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object
public partial class Channel
{
public string Id { get; }
public string ParentId { get; }
public string GuildId { get; }
public string Name { get; }
@@ -12,26 +16,22 @@
public ChannelType Type { get; }
public Channel(string id, string guildId, string name, string topic, ChannelType type)
public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type)
{
Id = id;
ParentId = parentId;
GuildId = guildId;
Name = name;
Topic = topic;
Type = type;
}
public override string ToString()
{
return Name;
}
public override string ToString() => Name;
}
public partial class Channel
{
public static Channel CreateDeletedChannel(string id)
{
return new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
public static Channel CreateDeletedChannel(string id) =>
new Channel(id, null, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
}
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
public enum ChannelType
{
GuildTextChat,
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class ChatLog
{
public Guild Guild { get; }
public Channel Channel { get; }
public DateTimeOffset? After { get; }
public DateTimeOffset? Before { get; }
public IReadOnlyList<Message> Messages { get; }
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTimeOffset? after, DateTimeOffset? before,
IReadOnlyList<Message> messages, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
After = after;
Before = before;
Messages = messages;
Mentionables = mentionables;
}
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup>
</Project>
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using System.Drawing;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object
public class Embed
{
public string Title { get; }
public string Url { get; }
public DateTimeOffset? Timestamp { get; }
public Color Color { get; }
public EmbedAuthor Author { get; }
public string Description { get; }
public IReadOnlyList<EmbedField> Fields { get; }
public EmbedImage Thumbnail { get; }
public EmbedImage Image { get; }
public EmbedFooter Footer { get; }
public Embed(string title, string url, DateTimeOffset? timestamp, Color color, EmbedAuthor author, string description,
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
{
Title = title;
Url = url;
Timestamp = timestamp;
Color = color;
Author = author;
Description = description;
Fields = fields;
Thumbnail = thumbnail;
Image = image;
Footer = footer;
}
public override string ToString() => Title;
}
}
@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
public class EmbedAuthor
{
public string Name { get; }
public string Url { get; }
public string IconUrl { get; }
public EmbedAuthor(string name, string url, string iconUrl)
{
Name = name;
Url = url;
IconUrl = iconUrl;
}
public override string ToString() => Name;
}
}
@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
public class EmbedField
{
public string Name { get; }
public string Value { get; }
public bool IsInline { get; }
public EmbedField(string name, string value, bool isInline)
{
Name = name;
Value = value;
IsInline = isInline;
}
public override string ToString() => $"{Name} | {Value}";
}
}
@@ -0,0 +1,19 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
public class EmbedFooter
{
public string Text { get; }
public string IconUrl { get; }
public EmbedFooter(string text, string iconUrl)
{
Text = text;
IconUrl = iconUrl;
}
public override string ToString() => Text;
}
}
@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
public class EmbedImage
{
public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public EmbedImage(string url, int? width, int? height)
{
Url = url;
Height = height;
Width = width;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji
{
public string Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public string ImageUrl { get; }
public Emoji(string id, string name, bool isAnimated)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
ImageUrl = GetImageUrl(id, name, isAnimated);
}
}
public partial class Emoji
{
private static IEnumerable<int> GetCodePoints(string emoji)
{
for (var i = 0; i < emoji.Length; i += char.IsHighSurrogate(emoji[i]) ? 2 : 1)
yield return char.ConvertToUtf32(emoji, i);
}
private static string GetTwemojiName(string emoji) =>
GetCodePoints(emoji).Select(i => i.ToString("x")).JoinToString("-");
public static string GetImageUrl(string id, string name, bool isAnimated)
{
// Custom emoji
if (!id.IsNullOrWhiteSpace())
{
// Animated
if (isAnimated)
return $"https://cdn.discordapp.com/emojis/{id}.gif";
// Non-animated
return $"https://cdn.discordapp.com/emojis/{id}.png";
}
// Standard unicode emoji (via twemoji)
var twemojiName = GetTwemojiName(name);
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
}
}
}
@@ -4,6 +4,7 @@
{
PlainText,
HtmlDark,
HtmlLight
HtmlLight,
Csv
}
}
@@ -12,6 +12,8 @@ namespace DiscordChatExporter.Core.Models
return "html";
if (format == ExportFormat.HtmlLight)
return "html";
if (format == ExportFormat.Csv)
return "csv";
throw new ArgumentOutOfRangeException(nameof(format));
}
@@ -24,6 +26,8 @@ namespace DiscordChatExporter.Core.Models
return "HTML (Dark)";
if (format == ExportFormat.HtmlLight)
return "HTML (Light)";
if (format == ExportFormat.Csv)
return "Comma Seperated Values (CSV)";
throw new ArgumentOutOfRangeException(nameof(format));
}
@@ -0,0 +1,84 @@
using System;
namespace DiscordChatExporter.Core.Models
{
// Loosely based on https://github.com/omar/ByteSize (MIT license)
public struct FileSize
{
public const long BytesInKiloByte = 1024;
public const long BytesInMegaByte = 1024 * BytesInKiloByte;
public const long BytesInGigaByte = 1024 * BytesInMegaByte;
public const long BytesInTeraByte = 1024 * BytesInGigaByte;
public const long BytesInPetaByte = 1024 * BytesInTeraByte;
public const string ByteSymbol = "B";
public const string KiloByteSymbol = "KB";
public const string MegaByteSymbol = "MB";
public const string GigaByteSymbol = "GB";
public const string TeraByteSymbol = "TB";
public const string PetaByteSymbol = "PB";
public double Bytes { get; }
public double KiloBytes => Bytes / BytesInKiloByte;
public double MegaBytes => Bytes / BytesInMegaByte;
public double GigaBytes => Bytes / BytesInGigaByte;
public double TeraBytes => Bytes / BytesInTeraByte;
public double PetaBytes => Bytes / BytesInPetaByte;
public string LargestWholeNumberSymbol
{
get
{
// Absolute value is used to deal with negative values
if (Math.Abs(PetaBytes) >= 1)
return PetaByteSymbol;
if (Math.Abs(TeraBytes) >= 1)
return TeraByteSymbol;
if (Math.Abs(GigaBytes) >= 1)
return GigaByteSymbol;
if (Math.Abs(MegaBytes) >= 1)
return MegaByteSymbol;
if (Math.Abs(KiloBytes) >= 1)
return KiloByteSymbol;
return ByteSymbol;
}
}
public double LargestWholeNumberValue
{
get
{
// Absolute value is used to deal with negative values
if (Math.Abs(PetaBytes) >= 1)
return PetaBytes;
if (Math.Abs(TeraBytes) >= 1)
return TeraBytes;
if (Math.Abs(GigaBytes) >= 1)
return GigaBytes;
if (Math.Abs(MegaBytes) >= 1)
return MegaBytes;
if (Math.Abs(KiloBytes) >= 1)
return KiloBytes;
return Bytes;
}
}
public FileSize(double bytes)
{
Bytes = bytes;
}
public override string ToString() => $"{LargestWholeNumberValue:0.##} {LargestWholeNumberSymbol}";
}
}
+40
View File
@@ -0,0 +1,40 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/guild#guild-object
public partial class Guild
{
public string Id { get; }
public string Name { get; }
public string IconHash { get; }
public string IconUrl { get; }
public Guild(string id, string name, string iconHash)
{
Id = id;
Name = name;
IconHash = iconHash;
IconUrl = GetIconUrl(id, iconHash);
}
public override string ToString() => Name;
}
public partial class Guild
{
public static string GetIconUrl(string id, string iconHash)
{
return !iconHash.IsNullOrWhiteSpace()
? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
}
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
}
}
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
public class Mentionables
{
public IReadOnlyList<User> Users { get; }
public IReadOnlyList<Channel> Channels { get; }
public IReadOnlyList<Role> Roles { get; }
public Mentionables(IReadOnlyList<User> users, IReadOnlyList<Channel> channels, IReadOnlyList<Role> roles)
{
Users = users;
Channels = channels;
Roles = roles;
}
public User GetUser(string id) =>
Users.FirstOrDefault(u => u.Id == id) ?? User.CreateUnknownUser(id);
public Channel GetChannel(string id) =>
Channels.FirstOrDefault(c => c.Id == id) ?? Channel.CreateDeletedChannel(id);
public Role GetRole(string id) =>
Roles.FirstOrDefault(r => r.Id == id) ?? Role.CreateDeletedRole(id);
}
}
@@ -3,6 +3,8 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object
public class Message
{
public string Id { get; }
@@ -13,42 +15,37 @@ namespace DiscordChatExporter.Core.Models
public User Author { get; }
public DateTime TimeStamp { get; }
public DateTimeOffset Timestamp { get; }
public DateTime? EditedTimeStamp { get; }
public DateTimeOffset? EditedTimestamp { get; }
public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<Embed> Embeds { get; }
public IReadOnlyList<Reaction> Reactions { get; }
public IReadOnlyList<User> MentionedUsers { get; }
public IReadOnlyList<Role> MentionedRoles { get; }
public IReadOnlyList<Channel> MentionedChannels { get; }
public Message(string id, string channelId, MessageType type,
User author, DateTime timeStamp,
DateTime? editedTimeStamp, string content,
IReadOnlyList<Attachment> attachments, IReadOnlyList<User> mentionedUsers,
IReadOnlyList<Role> mentionedRoles, IReadOnlyList<Channel> mentionedChannels)
public Message(string id, string channelId, MessageType type, User author, DateTimeOffset timestamp,
DateTimeOffset? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
{
Id = id;
ChannelId = channelId;
Type = type;
Author = author;
TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp;
Timestamp = timestamp;
EditedTimestamp = editedTimestamp;
Content = content;
Attachments = attachments;
Embeds = embeds;
Reactions = reactions;
MentionedUsers = mentionedUsers;
MentionedRoles = mentionedRoles;
MentionedChannels = mentionedChannels;
}
public override string ToString()
{
return Content;
}
public override string ToString() => Content;
}
}
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
public enum MessageType
{
Default,
@@ -0,0 +1,17 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#reaction-object
public class Reaction
{
public int Count { get; }
public Emoji Emoji { get; }
public Reaction(int count, Emoji emoji)
{
Count = count;
Emoji = emoji;
}
}
}
@@ -1,5 +1,7 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class Role
{
public string Id { get; }
@@ -12,17 +14,11 @@
Name = name;
}
public override string ToString()
{
return Name;
}
public override string ToString() => Name;
}
public partial class Role
{
public static Role CreateDeletedRole(string id)
{
return new Role(id, "deleted-role");
}
public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role");
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class User
{
public string Id { get; }
public int Discriminator { get; }
public string Name { get; }
public string FullName { get; }
public string AvatarHash { get; }
public string AvatarUrl { get; }
public bool IsBot { get; }
public User(string id, int discriminator, string name, string avatarHash, bool isBot)
{
Id = id;
Discriminator = discriminator;
Name = name;
AvatarHash = avatarHash;
IsBot = isBot;
FullName = GetFullName(name, discriminator);
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
}
public override string ToString() => FullName;
}
public partial class User
{
public static string GetFullName(string name, int discriminator) => $"{name}#{discriminator:0000}";
public static string GetAvatarUrl(string id, int discriminator, string avatarHash)
{
// Custom avatar
if (!avatarHash.IsNullOrWhiteSpace())
{
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
}
// Default avatar
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
}
public static User CreateUnknownUser(string id) => new User(id, 0, "Unknown", null, false);
}
}
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public class CsvChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _dateFormat;
public CsvChatLogRenderer(ChatLog chatLog, string dateFormat)
{
_chatLog = chatLog;
_dateFormat = dateFormat;
}
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private string FormatMarkdown(Node node)
{
// Formatted node
if (node is FormattedNode formattedNode)
{
// Recursively get inner text
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
}
// Non-meta mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
{
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
return $"@{user.Name}";
}
// Channel mention node
if (mentionNode.Type == MentionType.Channel)
{
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
return $"#{channel.Name}";
}
// Role mention node
if (mentionNode.Type == MentionType.Role)
{
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
return $"@{role.Name}";
}
}
// Custom emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
{
return $":{emojiNode.Name}:";
}
// All other nodes - simply return source
return node.Source;
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
private async Task RenderFieldAsync(TextWriter writer, string value)
{
var encodedValue = value.Replace("\"", "\"\"");
await writer.WriteAsync($"\"{encodedValue}\";");
}
private async Task RenderMessageAsync(TextWriter writer, Message message)
{
// Author
await RenderFieldAsync(writer, message.Author.FullName);
// Timestamp
await RenderFieldAsync(writer, FormatDate(message.Timestamp));
// Content
await RenderFieldAsync(writer, FormatMarkdown(message.Content));
// Attachments
var formattedAttachments = message.Attachments.Select(a => a.Url).JoinToString(",");
await RenderFieldAsync(writer, formattedAttachments);
// Reactions
var formattedReactions = message.Reactions.Select(r => r.Emoji.Name + $"({r.Count})").JoinToString(",");
await RenderFieldAsync(writer, formattedReactions);
// Line break
await writer.WriteLineAsync();
}
public async Task RenderAsync(TextWriter writer)
{
// Headers
await writer.WriteLineAsync("Author;Date;Content;Attachments;Reactions;");
// Log
foreach (var message in _chatLog.Messages)
await RenderMessageAsync(writer, message);
}
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\HtmlDark.css" />
<EmbeddedResource Include="Resources\HtmlDark.html" />
<EmbeddedResource Include="Resources\HtmlLight.css" />
<EmbeddedResource Include="Resources\HtmlLight.html" />
<EmbeddedResource Include="Resources\HtmlShared.css" />
<EmbeddedResource Include="Resources\HtmlShared.html" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Scriban" Version="2.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlChatLogRenderer
{
private class MessageGroup
{
public User Author { get; }
public DateTimeOffset Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Messages = messages;
}
}
}
}
@@ -0,0 +1,27 @@
using System.Reflection;
using System.Threading.Tasks;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlChatLogRenderer
{
private class TemplateLoader : ITemplateLoader
{
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Rendering.Resources";
public string Load(string templatePath) =>
Assembly.GetExecutingAssembly().GetManifestResourceString($"{ResourceRootNamespace}.{templatePath}");
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) => templateName;
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) => Load(templatePath);
public ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath) =>
new ValueTask<string>(Load(templatePath));
}
}
}
@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _themeName;
private readonly string _dateFormat;
public HtmlChatLogRenderer(ChatLog chatLog, string themeName, string dateFormat)
{
_chatLog = chatLog;
_themeName = themeName;
_dateFormat = dateFormat;
}
private string HtmlEncode(string s) => WebUtility.HtmlEncode(s);
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages) =>
messages.GroupContiguous((buffer, message) =>
{
// Break group if the author changed
if (buffer.Last().Author.Id != message.Author.Id)
return false;
// Break group if last message was more than 7 minutes ago
if ((message.Timestamp - buffer.Last().Timestamp).TotalMinutes > 7)
return false;
return true;
}).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
private string FormatMarkdown(Node node, bool isJumbo)
{
// Text node
if (node is TextNode textNode)
{
// Return HTML-encoded text
return HtmlEncode(textNode.Text);
}
// Formatted node
if (node is FormattedNode formattedNode)
{
// Recursively get inner html
var innerHtml = FormatMarkdown(formattedNode.Children, false);
// Bold
if (formattedNode.Formatting == TextFormatting.Bold)
return $"<strong>{innerHtml}</strong>";
// Italic
if (formattedNode.Formatting == TextFormatting.Italic)
return $"<em>{innerHtml}</em>";
// Underline
if (formattedNode.Formatting == TextFormatting.Underline)
return $"<u>{innerHtml}</u>";
// Strikethrough
if (formattedNode.Formatting == TextFormatting.Strikethrough)
return $"<s>{innerHtml}</s>";
// Spoiler
if (formattedNode.Formatting == TextFormatting.Spoiler)
return $"<span class=\"spoiler\">{innerHtml}</span>";
}
// Inline code block node
if (node is InlineCodeBlockNode inlineCodeBlockNode)
{
return $"<span class=\"pre pre--inline\">{HtmlEncode(inlineCodeBlockNode.Code)}</span>";
}
// Multi-line code block node
if (node is MultilineCodeBlockNode multilineCodeBlockNode)
{
// Set language class for syntax highlighting
var languageCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace()
? "language-" + multilineCodeBlockNode.Language
: null;
return $"<div class=\"pre pre--multiline {languageCssClass}\">{HtmlEncode(multilineCodeBlockNode.Code)}</div>";
}
// Mention node
if (node is MentionNode mentionNode)
{
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return $"<span class=\"mention\">@{HtmlEncode(mentionNode.Id)}</span>";
}
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
return $"<span class=\"mention\" title=\"{HtmlEncode(user.FullName)}\">@{HtmlEncode(user.Name)}</span>";
}
// Channel mention node
if (mentionNode.Type == MentionType.Channel)
{
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
return $"<span class=\"mention\">#{HtmlEncode(channel.Name)}</span>";
}
// Role mention node
if (mentionNode.Type == MentionType.Role)
{
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
return $"<span class=\"mention\">@{HtmlEncode(role.Name)}</span>";
}
}
// Emoji node
if (node is EmojiNode emojiNode)
{
// Get emoji image URL
var emojiImageUrl = Emoji.GetImageUrl(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated);
// Make emoji large if it's jumbo
var jumboableCssClass = isJumbo ? "emoji--large" : null;
return $"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />";
}
// Link node
if (node is LinkNode linkNode)
{
return $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\">{HtmlEncode(linkNode.Title)}</a>";
}
// All other nodes - simply return source
return node.Source;
}
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
{
// Emojis are jumbo if all top-level nodes are emoji nodes, disregarding whitespace
var isJumbo = isTopLevel && nodes.Where(n => !n.Source.IsNullOrWhiteSpace()).All(n => n is EmojiNode);
return nodes.Select(n => FormatMarkdown(n, isJumbo)).JoinToString("");
}
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown), true);
public async Task RenderAsync(TextWriter writer)
{
// Create template loader
var loader = new TemplateLoader();
// Get template
var templateCode = loader.Load($"Html{_themeName}.html");
var template = Template.Parse(templateCode);
// Create template context
var context = new TemplateContext
{
TemplateLoader = loader,
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
// Create template model
var model = new ScriptObject();
model.SetValue("Model", _chatLog, true);
model.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
model.Import(nameof(FormatDate), new Func<DateTimeOffset, string>(FormatDate));
model.Import(nameof(FormatMarkdown), new Func<string, string>(FormatMarkdown));
context.PushGlobal(model);
// Configure output
context.PushOutput(new TextWriterOutput(writer));
// HACK: Render output in a separate thread
// (even though Scriban has async API, it still makes a lot of blocking CPU-bound calls)
await Task.Run(async () => await context.EvaluateAsync(template.Page));
}
}
}
@@ -0,0 +1,10 @@
using System.IO;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Rendering
{
public interface IChatLogRenderer
{
Task RenderAsync(TextWriter writer);
}
}
@@ -0,0 +1,218 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public class PlainTextChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _dateFormat;
public PlainTextChatLogRenderer(ChatLog chatLog, string dateFormat)
{
_chatLog = chatLog;
_dateFormat = dateFormat;
}
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private string FormatDateRange(DateTimeOffset? after, DateTimeOffset? before)
{
// Both 'after' and 'before'
if (after != null && before != null)
return $"{FormatDate(after.Value)} to {FormatDate(before.Value)}";
// Just 'after'
if (after != null)
return $"after {FormatDate(after.Value)}";
// Just 'before'
if (before != null)
return $"before {FormatDate(before.Value)}";
// Neither
return null;
}
private string FormatMarkdown(Node node)
{
// Formatted node
if (node is FormattedNode formattedNode)
{
// Recursively get inner text
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
}
// Non-meta mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
{
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
return $"@{user.Name}";
}
// Channel mention node
if (mentionNode.Type == MentionType.Channel)
{
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
return $"#{channel.Name}";
}
// Role mention node
if (mentionNode.Type == MentionType.Role)
{
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
return $"@{role.Name}";
}
}
// Custom emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
{
return $":{emojiNode.Name}:";
}
// All other nodes - simply return source
return node.Source;
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
private async Task RenderAttachmentsAsync(TextWriter writer, IReadOnlyList<Attachment> attachments)
{
if (attachments.Any())
{
await writer.WriteLineAsync("{Attachments}");
foreach (var attachment in attachments)
await writer.WriteLineAsync(attachment.Url);
await writer.WriteLineAsync();
}
}
private async Task RenderEmbedsAsync(TextWriter writer, IReadOnlyList<Embed> embeds)
{
foreach (var embed in embeds)
{
await writer.WriteLineAsync("{Embed}");
// Author name
if (!(embed.Author?.Name).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Author?.Name);
// URL
if (!embed.Url.IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Url);
// Title
if (!embed.Title.IsNullOrWhiteSpace())
await writer.WriteLineAsync(FormatMarkdown(embed.Title));
// Description
if (!embed.Description.IsNullOrWhiteSpace())
await writer.WriteLineAsync(FormatMarkdown(embed.Description));
// Fields
foreach (var field in embed.Fields)
{
// Name
if (!field.Name.IsNullOrWhiteSpace())
await writer.WriteLineAsync(field.Name);
// Value
if (!field.Value.IsNullOrWhiteSpace())
await writer.WriteLineAsync(field.Value);
}
// Thumbnail URL
if (!(embed.Thumbnail?.Url).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Thumbnail?.Url);
// Image URL
if (!(embed.Image?.Url).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Image?.Url);
// Footer text
if (!(embed.Footer?.Text).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Footer?.Text);
await writer.WriteLineAsync();
}
}
private async Task RenderReactionsAsync(TextWriter writer, IReadOnlyList<Reaction> reactions)
{
if (reactions.Any())
{
await writer.WriteLineAsync("{Reactions}");
foreach (var reaction in reactions)
{
await writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1)
await writer.WriteAsync($" ({reaction.Count})");
await writer.WriteAsync(" ");
}
await writer.WriteLineAsync();
await writer.WriteLineAsync();
}
}
private async Task RenderMessageAsync(TextWriter writer, Message message)
{
// Timestamp and author
await writer.WriteLineAsync($"[{FormatDate(message.Timestamp)}] {message.Author.FullName}");
// Content
await writer.WriteLineAsync(FormatMarkdown(message.Content));
// Separator
await writer.WriteLineAsync();
// Attachments
await RenderAttachmentsAsync(writer, message.Attachments);
// Embeds
await RenderEmbedsAsync(writer, message.Embeds);
// Reactions
await RenderReactionsAsync(writer, message.Reactions);
}
public async Task RenderAsync(TextWriter writer)
{
// Metadata
await writer.WriteLineAsync('='.Repeat(62));
await writer.WriteLineAsync($"Guild: {_chatLog.Guild.Name}");
await writer.WriteLineAsync($"Channel: {_chatLog.Channel.Name}");
await writer.WriteLineAsync($"Topic: {_chatLog.Channel.Topic}");
await writer.WriteLineAsync($"Messages: {_chatLog.Messages.Count:N0}");
await writer.WriteLineAsync($"Range: {FormatDateRange(_chatLog.After, _chatLog.Before)}");
await writer.WriteLineAsync('='.Repeat(62));
await writer.WriteLineAsync();
// Log
foreach (var message in _chatLog.Messages)
await RenderMessageAsync(writer, message);
}
}
}
@@ -0,0 +1,100 @@
/* === GENERAL === */
body {
background-color: #36393e;
color: #dcddde;
}
a {
color: #0096cf;
}
.spoiler {
background-color: rgba(255, 255, 255, 0.1);
}
.pre {
background-color: #2f3136 !important;
}
.pre--multiline {
border-color: #282b30 !important;
color: #839496 !important;
}
.mention {
color: #7289da;
}
/* === INFO === */
.info__guild-name {
color: #ffffff;
}
.info__channel-name {
color: #ffffff;
}
.info__channel-topic {
color: #ffffff;
}
/* === CHATLOG === */
.chatlog__message-group {
border-color: rgba(255, 255, 255, 0.1);
}
.chatlog__author-name {
color: #ffffff;
}
.chatlog__timestamp {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__edited-timestamp {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__embed-content-container {
background-color: rgba(46, 48, 54, 0.3);
border-color: rgba(46, 48, 54, 0.6);
}
.chatlog__embed-author-name {
color: #ffffff;
}
.chatlog__embed-author-name-link {
color: #ffffff;
}
.chatlog__embed-title {
color: #ffffff;
}
.chatlog__embed-description {
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-field-name {
color: #ffffff;
}
.chatlog__embed-field-value {
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-footer {
color: rgba(255, 255, 255, 0.6);
}
.chatlog__reaction {
background-color: rgba(255, 255, 255, 0.05);
}
.chatlog__reaction-count {
color: rgba(255, 255, 255, 0.3);
}
@@ -0,0 +1,3 @@
{{~ ThemeStyleSheet = include "HtmlDark.css" ~}}
{{~ HighlightJsStyleName = "solarized-dark" ~}}
{{~ include "HtmlShared.html" ~}}
@@ -0,0 +1,101 @@
/* === GENERAL === */
body {
background-color: #ffffff;
color: #747f8d;
}
a {
color: #00b0f4;
}
.spoiler {
background-color: rgba(0, 0, 0, 0.1);
}
.pre {
background-color: #f9f9f9 !important;
}
.pre--multiline {
border-color: #f3f3f3 !important;
color: #657b83 !important;
}
.mention {
background-color: #f1f3fb;
color: #7289da;
}
/* === INFO === */
.info__guild-name {
color: #2f3136;
}
.info__channel-name {
color: #2f3136;
}
.info__channel-topic {
color: #2f3136;
}
/* === CHATLOG === */
.chatlog__message-group {
border-color: #eceeef;
}
.chatlog__author-name {
color: #2f3136;
}
.chatlog__timestamp {
color: #99aab5;
}
.chatlog__edited-timestamp {
color: #99aab5;
}
.chatlog__embed-content-container {
background-color: rgba(249, 249, 249, 0.3);
border-color: rgba(204, 204, 204, 0.3);
}
.chatlog__embed-author-name {
color: #4f545c;
}
.chatlog__embed-author-name-link {
color: #4f545c;
}
.chatlog__embed-title {
color: #4f545c;
}
.chatlog__embed-description {
color: #737f8d;
}
.chatlog__embed-field-name {
color: #36393e;
}
.chatlog__embed-field-value {
color: #737f8d;
}
.chatlog__embed-footer {
color: rgba(79, 83, 91, 0.4);
}
.chatlog__reaction {
background-color: rgba(79, 84, 92, 0.06);
}
.chatlog__reaction-count {
color: #99aab5;
}
@@ -0,0 +1,3 @@
{{~ ThemeStyleSheet = include "HtmlLight.css" ~}}
{{~ HighlightJsStyleName = "solarized-light" ~}}
{{~ include "HtmlShared.html" ~}}
@@ -0,0 +1,356 @@
/* === GENERAL === */
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
font-weight: 300;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
font-weight: 400;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
font-weight: 500;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
font-weight: 600;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
font-weight: 700;
}
body {
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
object-fit: contain;
}
.markdown {
white-space: pre-wrap;
line-height: 1.3;
overflow-wrap: break-word;
}
.spoiler {
border-radius: 3px;
}
.pre {
font-family: "Consolas", "Courier New", Courier, Monospace;
}
.pre--multiline {
margin-top: 4px;
padding: 8px;
border: 2px solid;
border-radius: 5px;
}
.pre--inline {
padding: 2px;
border-radius: 3px;
font-size: 85%;
}
.mention {
font-weight: 500;
}
.emoji {
width: 1.45em;
height: 1.45em;
margin: 0 1px;
vertical-align: -0.4em;
}
.emoji--small {
width: 1rem;
height: 1rem;
}
.emoji--large {
width: 2rem;
height: 2rem;
}
/* === INFO === */
.info {
display: flex;
max-width: 100%;
margin: 0 5px 10px 5px;
}
.info__guild-icon-container {
flex: 0;
}
.info__guild-icon {
max-width: 88px;
max-height: 88px;
}
.info__metadata {
flex: 1;
margin-left: 10px;
}
.info__guild-name {
font-size: 1.4em;
}
.info__channel-name {
font-size: 1.2em;
}
.info__channel-topic {
margin-top: 2px;
}
.info__channel-message-count {
margin-top: 2px;
}
.info__channel-date-range {
margin-top: 2px;
}
/* === CHATLOG === */
.chatlog {
max-width: 100%;
margin-bottom: 24px;
}
.chatlog__message-group {
display: flex;
margin: 0 10px;
padding: 15px 0;
border-top: 1px solid;
}
.chatlog__author-avatar-container {
flex: 0;
width: 40px;
height: 40px;
}
.chatlog__author-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
.chatlog__messages {
flex: 1;
min-width: 50%;
margin-left: 20px;
}
.chatlog__author-name {
font-size: 1em;
font-weight: 500;
}
.chatlog__timestamp {
margin-left: 5px;
font-size: .75em;
}
.chatlog__content {
padding-top: 5px;
font-size: .9375em;
word-wrap: break-word;
}
.chatlog__edited-timestamp {
margin-left: 3px;
font-size: .8em;
}
.chatlog__attachment {
margin: 5px 0;
}
.chatlog__attachment-thumbnail {
max-width: 50%;
max-height: 500px;
border-radius: 3px;
}
.chatlog__embed {
display: flex;
max-width: 520px;
margin-top: 5px;
}
.chatlog__embed-color-pill {
flex-shrink: 0;
width: 4px;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.chatlog__embed-content-container {
display: flex;
flex-direction: column;
padding: 8px 10px;
border: 1px solid;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.chatlog__embed-content {
width: 100%;
display: flex;
}
.chatlog__embed-text {
flex: 1;
}
.chatlog__embed-author {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.chatlog__embed-author-icon {
width: 20px;
height: 20px;
margin-right: 9px;
border-radius: 50%;
}
.chatlog__embed-author-name {
font-size: .875em;
font-weight: 600;
}
.chatlog__embed-title {
margin-bottom: 4px;
font-size: .875em;
font-weight: 600;
}
.chatlog__embed-description {
font-weight: 500;
font-size: 14px;
}
.chatlog__embed-fields {
display: flex;
flex-wrap: wrap;
}
.chatlog__embed-field {
flex: 0;
min-width: 100%;
max-width: 506px;
padding-top: 10px;
}
.chatlog__embed-field--inline {
flex: 1;
flex-basis: auto;
min-width: 150px;
}
.chatlog__embed-field-name {
margin-bottom: 4px;
font-size: .875em;
font-weight: 600;
}
.chatlog__embed-field-value {
font-size: .875em;
font-weight: 500;
}
.chatlog__embed-thumbnail {
flex: 0;
margin-left: 20px;
max-width: 80px;
max-height: 80px;
border-radius: 3px;
}
.chatlog__embed-image-container {
margin-top: 10px;
}
.chatlog__embed-image {
max-width: 500px;
max-height: 400px;
border-radius: 3px;
}
.chatlog__embed-footer {
margin-top: 10px;
}
.chatlog__embed-footer-icon {
margin-right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
}
.chatlog__embed-footer-text {
font-weight: 500;
font-size: .75em;
}
.chatlog__reactions {
display: flex;
}
.chatlog__reaction {
display: flex;
align-items: center;
margin: 6px 2px 2px 2px;
padding: 3px 6px;
border-radius: 3px;
}
.chatlog__reaction-count {
min-width: 9px;
margin-left: 6px;
font-size: .875em;
}
.chatlog__bot-tag {
margin-left: 0.3em;
background: #7289da;
color: #ffffff;
font-size: 0.625em;
padding: 1px 2px;
border-radius: 3px;
vertical-align: middle;
line-height: 1.3;
position: relative;
top: -.2em;
}
@@ -0,0 +1,228 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{~ # Metadata ~}}
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
{{~ # Styles ~}}
<style>
{{ include "HtmlShared.css" }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach((block) => {
hljs.highlightBlock(block);
});
});
</script>
</head>
<body>
{{~ # Info ~}}
<div class="info">
<div class="info__guild-icon-container">
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
</div>
<div class="info__metadata">
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{~ if Model.Channel.Topic ~}}
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{~ end ~}}
<div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div>
{{~ if Model.After || Model.Before ~}}
<div class="info__channel-date-range">
{{~ if Model.After && Model.Before ~}}
Between {{ Model.After | FormatDate | html.escape }} and {{ Model.Before | FormatDate | html.escape }}
{{~ else if Model.After ~}}
After {{ Model.After | FormatDate | html.escape }}
{{~ else if Model.Before ~}}
Before {{ Model.Before | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ # Log ~}}
<div class="chatlog">
{{~ for group in Model.Messages | GroupMessages ~}}
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}" data-user-id="{{ group.Author.Id | html.escape }}">{{ group.Author.Name | html.escape }}</span>
{{~ if group.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span>
{{~ end ~}}
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
<span class="markdown">{{ message.Content | FormatMarkdown }}</span>
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
<a href="{{ attachment.Url }}">
{{ # Image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
</div>
{{~ end ~}}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
{{~ end ~}}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
{{~ else ~}}
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
</a>
</div>
{{~ end ~}}
</div>
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
</a>
</div>
{{~ end ~}}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{~ end ~}}
{{~ end ~}}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}" />
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ end ~}}
</div>
</div>
{{~ end ~}}
</div>
</body>
</html>
@@ -0,0 +1,208 @@
using System;
using System.Drawing;
using System.Linq;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services.Internal;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService
{
private User ParseUser(JToken json)
{
var id = json["id"].Value<string>();
var discriminator = json["discriminator"].Value<int>();
var name = json["username"].Value<string>();
var avatarHash = json["avatar"].Value<string>();
var isBot = json["bot"]?.Value<bool>() ?? false;
return new User(id, discriminator, name, avatarHash, isBot);
}
private Guild ParseGuild(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
var iconHash = json["icon"].Value<string>();
return new Guild(id, name, iconHash);
}
private Channel ParseChannel(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var parentId = json["parent_id"]?.Value<string>();
var type = (ChannelType) json["type"].Value<int>();
var topic = json["topic"]?.Value<string>();
// Try to extract guild ID
var guildId = json["guild_id"]?.Value<string>();
// If the guild ID is blank, it's direct messages
if (guildId.IsNullOrWhiteSpace())
guildId = Guild.DirectMessages.Id;
// Try to extract name
var name = json["name"]?.Value<string>();
// If the name is blank, it's direct messages
if (name.IsNullOrWhiteSpace())
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
return new Channel(id, parentId, guildId, name, topic, type);
}
private Role ParseRole(JToken json)
{
var id = json["id"].Value<string>();
var name = json["name"].Value<string>();
return new Role(id, name);
}
private Attachment ParseAttachment(JToken json)
{
var id = json["id"].Value<string>();
var url = json["url"].Value<string>();
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
var fileName = json["filename"].Value<string>();
var fileSizeBytes = json["size"].Value<long>();
var fileSize = new FileSize(fileSizeBytes);
return new Attachment(id, width, height, url, fileName, fileSize);
}
private EmbedAuthor ParseEmbedAuthor(JToken json)
{
var name = json["name"]?.Value<string>();
var url = json["url"]?.Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedAuthor(name, url, iconUrl);
}
private EmbedField ParseEmbedField(JToken json)
{
var name = json["name"].Value<string>();
var value = json["value"].Value<string>();
var isInline = json["inline"]?.Value<bool>() ?? false;
return new EmbedField(name, value, isInline);
}
private EmbedImage ParseEmbedImage(JToken json)
{
var url = json["url"]?.Value<string>();
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
return new EmbedImage(url, width, height);
}
private EmbedFooter ParseEmbedFooter(JToken json)
{
var text = json["text"].Value<string>();
var iconUrl = json["icon_url"]?.Value<string>();
return new EmbedFooter(text, iconUrl);
}
private Embed ParseEmbed(JToken json)
{
// Get basic data
var title = json["title"]?.Value<string>();
var description = json["description"]?.Value<string>();
var url = json["url"]?.Value<string>();
var timestamp = json["timestamp"]?.Value<DateTime>().ToDateTimeOffset();
// Get color
var color = json["color"] != null
? Color.FromArgb(json["color"].Value<int>()).ResetAlpha()
: Color.FromArgb(79, 84, 92); // default color
// Get author
var author = json["author"] != null ? ParseEmbedAuthor(json["author"]) : null;
// Get fields
var fields = json["fields"].EmptyIfNull().Select(ParseEmbedField).ToArray();
// Get thumbnail
var thumbnail = json["thumbnail"] != null ? ParseEmbedImage(json["thumbnail"]) : null;
// Get image
var image = json["image"] != null ? ParseEmbedImage(json["image"]) : null;
// Get footer
var footer = json["footer"] != null ? ParseEmbedFooter(json["footer"]) : null;
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
}
private Emoji ParseEmoji(JToken json)
{
var id = json["id"]?.Value<string>();
var name = json["name"]?.Value<string>();
var isAnimated = json["animated"]?.Value<bool>() ?? false;
return new Emoji(id, name, isAnimated);
}
private Reaction ParseReaction(JToken json)
{
var count = json["count"].Value<int>();
var emoji = ParseEmoji(json["emoji"]);
return new Reaction(count, emoji);
}
private Message ParseMessage(JToken json)
{
// Get basic data
var id = json["id"].Value<string>();
var channelId = json["channel_id"].Value<string>();
var timestamp = json["timestamp"].Value<DateTime>().ToDateTimeOffset();
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>()?.ToDateTimeOffset();
var content = json["content"].Value<string>();
var type = (MessageType) json["type"].Value<int>();
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(json["author"]);
// Get attachments
var attachments = json["attachments"].EmptyIfNull().Select(ParseAttachment).ToArray();
// Get embeds
var embeds = json["embeds"].EmptyIfNull().Select(ParseEmbed).ToArray();
// Get reactions
var reactions = json["reactions"].EmptyIfNull().Select(ParseReaction).ToArray();
// Get mentioned users
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray();
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
reactions, mentionedUsers);
}
}
}
@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Internal;
using Failsafe;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDisposable
{
private readonly HttpClient _httpClient = new HttpClient();
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters)
{
// Create retry policy
var retry = Retry.Create()
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode >= 500)
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode == 429)
.WithMaxTryCount(10)
.WithDelay(TimeSpan.FromSeconds(0.4));
// Send request
return await retry.ExecuteAsync(async () =>
{
// Create request
const string apiRoot = "https://discordapp.com/api/v6";
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{apiRoot}/{resource}/{endpoint}"))
{
// Set authorization header
request.Headers.Authorization = token.Type == AuthTokenType.Bot
? new AuthenticationHeaderValue("Bot", token.Value)
: new AuthenticationHeaderValue(token.Value);
// Add parameters
foreach (var parameter in parameters)
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
// Skip empty values
if (value.IsNullOrWhiteSpace())
continue;
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
}
// Get response
using (var response = await _httpClient.SendAsync(request))
{
// Check status code
// We throw our own exception here because default one doesn't have status code
if (!response.IsSuccessStatusCode)
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
// Get content
var raw = await response.Content.ReadAsStringAsync();
// Parse
return JToken.Parse(raw);
}
}
});
}
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
{
// Special case for direct messages pseudo-guild
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
var response = await GetApiResponseAsync(token, "guilds", guildId);
var guild = ParseGuild(response);
return guild;
}
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
{
var response = await GetApiResponseAsync(token, "channels", channelId);
var channel = ParseChannel(response);
return channel;
}
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token)
{
var response = await GetApiResponseAsync(token, "users", "@me/guilds", "limit=100");
var guilds = response.Select(ParseGuild).ToArray();
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
{
var response = await GetApiResponseAsync(token, "users", "@me/channels");
var channels = response.Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/channels");
var channels = response.Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId)
{
var response = await GetApiResponseAsync(token, "guilds", $"{guildId}/roles");
var roles = response.Select(ParseRole).ToArray();
return roles;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
{
var result = new List<Message>();
// Get the last message
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=1", $"before={before?.ToSnowflake()}");
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
// If the last message doesn't exist or it's outside of range - return
if (lastMessage == null || lastMessage.Timestamp < after)
{
progress?.Report(1);
return result;
}
// Get other messages
var offsetId = after?.ToSnowflake() ?? "0";
while (true)
{
// Get message batch
response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=100", $"after={offsetId}");
// Parse
var messages = response
.Select(ParseMessage)
.Reverse() // reverse because messages appear newest first
.ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
break;
// Trim messages to range (until last message)
var messagesInRange = messages
.TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp)
.ToArray();
// Add to result
result.AddRange(messagesInRange);
// Break if messages were trimmed (which means the last message was encountered)
if (messagesInRange.Length != messages.Length)
break;
// Report progress (based on the time range of parsed messages compared to total)
progress?.Report((result.Last().Timestamp - result.First().Timestamp).TotalSeconds /
(lastMessage.Timestamp - result.First().Timestamp).TotalSeconds);
// Move offset
offsetId = result.Last().Id;
}
// Add last message
result.Add(lastMessage);
// Report progress
progress?.Report(1);
return result;
}
public async Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages)
{
// Get channels and roles
var channels = guildId != Guild.DirectMessages.Id
? await GetGuildChannelsAsync(token, guildId)
: Array.Empty<Channel>();
var roles = guildId != Guild.DirectMessages.Id
? await GetGuildRolesAsync(token, guildId)
: Array.Empty<Role>();
// Get users
var userMap = new Dictionary<string, User>();
foreach (var message in messages)
{
// Author
userMap[message.Author.Id] = message.Author;
// Mentioned users
foreach (var mentionedUser in message.MentionedUsers)
userMap[mentionedUser.Id] = mentionedUser;
}
var users = userMap.Values.ToArray();
return new Mentionables(users, channels, roles);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
{
// Get messages
var messages = await GetChannelMessagesAsync(token, channel.Id, after, before, progress);
// Get mentionables
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
return new ChatLog(guild, channel, after, before, messages, mentionables);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Channel channel,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
{
// Get guild
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await GetGuildAsync(token, channel.GuildId);
// Get the chat log
return await GetChatLogAsync(token, guild, channel, after, before, progress);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
{
// Get channel
var channel = await GetChannelAsync(token, channelId);
// Get the chat log
return await GetChatLogAsync(token, channel, after, before, progress);
}
public void Dispose()
{
_httpClient.Dispose();
}
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Failsafe" Version="1.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="Onova" Version="2.4.3" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
<ProjectReference Include="..\DiscordChatExporter.Core.Rendering\DiscordChatExporter.Core.Rendering.csproj" />
</ItemGroup>
</Project>
@@ -1,15 +1,19 @@
using System;
using System.Net;
namespace DiscordChatExporter.Core.Exceptions
namespace DiscordChatExporter.Core.Services.Exceptions
{
public class HttpErrorStatusCodeException : Exception
{
public HttpStatusCode StatusCode { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode)
public string ReasonPhrase { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
{
StatusCode = statusCode;
ReasonPhrase = reasonPhrase;
}
}
}
@@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Rendering;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public class ExportService
{
private readonly SettingsService _settingsService;
public ExportService(SettingsService settingsService)
{
_settingsService = settingsService;
}
private IChatLogRenderer CreateRenderer(ChatLog chatLog, ExportFormat format)
{
if (format == ExportFormat.PlainText)
return new PlainTextChatLogRenderer(chatLog, _settingsService.DateFormat);
if (format == ExportFormat.HtmlDark)
return new HtmlChatLogRenderer(chatLog, "Dark", _settingsService.DateFormat);
if (format == ExportFormat.HtmlLight)
return new HtmlChatLogRenderer(chatLog, "Light", _settingsService.DateFormat);
if (format == ExportFormat.Csv)
return new CsvChatLogRenderer(chatLog, _settingsService.DateFormat);
throw new ArgumentOutOfRangeException(nameof(format), $"Unknown format [{format}].");
}
private async Task ExportChatLogAsync(ChatLog chatLog, string filePath, ExportFormat format)
{
// Create output directory
var dirPath = Path.GetDirectoryName(filePath);
if (!dirPath.IsNullOrWhiteSpace())
Directory.CreateDirectory(dirPath);
// Render chat log to output file
using (var writer = File.CreateText(filePath))
await CreateRenderer(chatLog, format).RenderAsync(writer);
}
public async Task ExportChatLogAsync(ChatLog chatLog, string filePath, ExportFormat format, int? partitionLimit)
{
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
if (partitionLimit == null || partitionLimit <= 0 || chatLog.Messages.Count <= partitionLimit)
{
await ExportChatLogAsync(chatLog, filePath, format);
}
// Otherwise split into partitions and export separately
else
{
// Create partitions by grouping up to X contiguous messages into separate chat logs
var partitions = chatLog.Messages.GroupContiguous(g => g.Count < partitionLimit.Value)
.Select(g => new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.After, chatLog.Before, g, chatLog.Mentionables))
.ToArray();
// Split file path into components
var dirPath = Path.GetDirectoryName(filePath);
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var fileExt = Path.GetExtension(filePath);
// Export each partition separately
var partitionNumber = 1;
foreach (var partition in partitions)
{
// Compose new file name
var partitionFilePath = $"{fileNameWithoutExt} [{partitionNumber} of {partitions.Length}]{fileExt}";
// Compose full file path
if (!dirPath.IsNullOrWhiteSpace())
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
// Export
await ExportChatLogAsync(partition, partitionFilePath, format);
// Increment partition number
partitionNumber++;
}
}
}
}
}
@@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services.Helpers
{
public static class ExportHelper
{
public static bool IsDirectoryPath(string path) =>
path.Last() == Path.DirectorySeparatorChar ||
path.Last() == Path.AltDirectorySeparatorChar ||
(Path.GetExtension(path).IsNullOrWhiteSpace() && !File.Exists(path));
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
DateTimeOffset? after = null, DateTimeOffset? before = null)
{
var result = new StringBuilder();
// Append guild and channel names
result.Append($"{guild.Name} - {channel.Name} [{channel.Id}]");
// Append date range
if (after != null || before != null)
{
result.Append(" (");
// Both 'after' and 'before' are set
if (after != null && before != null)
{
result.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
}
// Only 'after' is set
else if (after != null)
{
result.Append($"after {after:yyyy-MM-dd}");
}
// Only 'before' is set
else
{
result.Append($"before {before:yyyy-MM-dd}");
}
result.Append(")");
}
// Append extension
result.Append($".{format.GetFileExtension()}");
// Replace invalid chars
foreach (var invalidChar in Path.GetInvalidFileNameChars())
result.Replace(invalidChar, '_');
return result.ToString();
}
}
}
@@ -0,0 +1,18 @@
using System;
using System.Drawing;
namespace DiscordChatExporter.Core.Services.Internal
{
internal static class Extensions
{
public static DateTimeOffset ToDateTimeOffset(this DateTime dateTime) => new DateTimeOffset(dateTime);
public static string ToSnowflake(this DateTimeOffset dateTime)
{
var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
return value.ToString();
}
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
}
}
@@ -3,15 +3,15 @@ using Tyrrrz.Settings;
namespace DiscordChatExporter.Core.Services
{
public class SettingsService : SettingsManager, ISettingsService
public class SettingsService : SettingsManager
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; }
public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public int? LastPartitionLimit { get; set; }
public SettingsService()
{
@@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
<Version>2.4</Version>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Onova" Version="2.1.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.0" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
</ItemGroup>
</Project>
@@ -1,22 +0,0 @@
using System.IO;
using System.Reflection;
using System.Resources;
namespace DiscordChatExporter.Core.Internal
{
internal static class Extensions
{
public static string GetManifestResourceString(this Assembly assembly, string resourceName)
{
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
throw new MissingManifestResourceException($"Could not find resource [{resourceName}].");
using (stream)
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
}
@@ -1,24 +0,0 @@
namespace DiscordChatExporter.Core.Models
{
public class Attachment
{
public string Id { get; }
public AttachmentType Type { get; }
public string Url { get; }
public string FileName { get; }
public long FileSize { get; }
public Attachment(string id, AttachmentType type, string url, string fileName, long fileSize)
{
Id = id;
Type = type;
Url = url;
FileName = fileName;
FileSize = fileSize;
}
}
}
@@ -1,24 +0,0 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class ChannelChatLog
{
public Guild Guild { get; }
public Channel Channel { get; }
public IReadOnlyList<MessageGroup> MessageGroups { get; }
public int TotalMessageCount { get; }
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
int totalMessageCount)
{
Guild = guild;
Channel = channel;
MessageGroups = messageGroups;
TotalMessageCount = totalMessageCount;
}
}
}
-38
View File
@@ -1,38 +0,0 @@
using System.Collections.Generic;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
public partial class Guild
{
public string Id { get; }
public string Name { get; }
public string IconHash { get; }
public string IconUrl => IconHash.IsNotBlank()
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
public IReadOnlyList<Role> Roles { get; }
public Guild(string id, string name, string iconHash, IReadOnlyList<Role> roles)
{
Id = id;
Name = name;
IconHash = iconHash;
Roles = roles;
}
public override string ToString()
{
return Name;
}
}
public partial class Guild
{
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, new Role[0]);
}
}
@@ -1,21 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class MessageGroup
{
public User Author { get; }
public DateTime TimeStamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timeStamp, IReadOnlyList<Message> messages)
{
Author = author;
TimeStamp = timeStamp;
Messages = messages;
}
}
}
-36
View File
@@ -1,36 +0,0 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
public class User
{
public string Id { get; }
public int Discriminator { get; }
public string Name { get; }
public string FullName => $"{Name}#{Discriminator:0000}";
public string AvatarHash { get; }
public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarUrl => AvatarHash.IsNotBlank()
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png"
: $"https://cdn.discordapp.com/embed/avatars/{DefaultAvatarHash}.png";
public User(string id, int discriminator, string name, string avatarHash)
{
Id = id;
Discriminator = discriminator;
Name = name;
AvatarHash = avatarHash;
}
public override string ToString()
{
return FullName;
}
}
}
@@ -1,142 +0,0 @@
body {
background-color: #36393E;
color: rgba(255, 255, 255, 0.7);
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #0096CF;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #2F3136;
color: rgb(131, 148, 150);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
background-color: #2F3136;
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre-wrap;
}
div#info {
display: flex;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
max-width: 100%;
}
div#log {
max-width: 100%;
}
img.guild-icon {
max-height: 64px;
max-width: 64px;
}
div.info-right {
flex: 1;
margin-left: 10px;
}
div.guild-name {
color: #FFFFFF;
font-size: 1.4em;
}
div.channel-name {
color: #FFFFFF;
font-size: 1.2em;
}
div.channel-topic {
margin-top: 2px;
color: #FFFFFF;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid rgba(255, 255, 255, 0.04);
display: flex;
margin-left: 10px;
margin-right: 10px;
padding-bottom: 15px;
padding-top: 15px;
}
div.msg-left {
height: 40px;
width: 40px;
}
img.msg-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
div.msg-right {
flex: 1;
margin-left: 20px;
min-width: 50%;
}
span.msg-user {
color: #FFFFFF;
font-size: 1em;
}
span.msg-date {
color: rgba(255, 255, 255, 0.2);
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: rgba(255, 255, 255, 0.2);
font-size: .8em;
margin-left: 5px;
}
div.msg-content {
font-size: .9375em;
padding-top: 5px;
word-wrap: break-word;
}
div.msg-attachment {
margin-bottom: 5px;
margin-top: 5px;
}
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -1,142 +0,0 @@
body {
background-color: #FFFFFF;
color: #737F8D;
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 16px;
}
a {
color: #00B0F4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
div.pre {
background-color: #F9F9F9;
color: rgb(101, 123, 131);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
background-color: #F9F9F9;
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre-wrap;
}
div#info {
display: flex;
margin-bottom: 10px;
margin-left: 5px;
margin-right: 5px;
max-width: 100%;
}
div#log {
max-width: 100%;
}
img.guild-icon {
max-height: 64px;
max-width: 64px;
}
div.info-right {
flex: 1;
margin-left: 10px;
}
div.guild-name {
color: #2F3136;
font-size: 1.4em;
}
div.channel-name {
color: #2F3136;
font-size: 1.2em;
}
div.channel-topic {
margin-top: 2px;
color: #2F3136;
}
div.channel-messagecount {
margin-top: 2px;
}
div.msg {
border-top: 1px solid #ECEEEF;
display: flex;
margin-left: 10px;
margin-right: 10px;
padding-bottom: 15px;
padding-top: 15px;
}
div.msg-left {
height: 40px;
width: 40px;
}
img.msg-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
div.msg-right {
flex: 1;
margin-left: 20px;
min-width: 50%;
}
span.msg-user {
color: #2F3136;
font-size: 1em;
}
span.msg-date {
color: #99AAB5;
font-size: .75em;
margin-left: 5px;
}
span.msg-edited {
color: #99AAB5;
font-size: .8em;
margin-left: 5px;
}
div.msg-content {
font-size: .9375em;
padding-top: 5px;
word-wrap: break-word;
}
div.msg-attachment {
margin-bottom: 5px;
margin-top: 5px;
}
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -1,326 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDataService, IDisposable
{
private const string ApiRoot = "https://discordapp.com/api/v6";
private readonly HttpClient _httpClient = new HttpClient();
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
private User ParseUser(JToken token)
{
var id = token["id"].Value<string>();
var discriminator = token["discriminator"].Value<int>();
var name = token["username"].Value<string>();
var avatarHash = token["avatar"].Value<string>();
return new User(id, discriminator, name, avatarHash);
}
private Role ParseRole(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
return new Role(id, name);
}
private Guild ParseGuild(JToken token)
{
var id = token["id"].Value<string>();
var name = token["name"].Value<string>();
var iconHash = token["icon"].Value<string>();
var roles = token["roles"].Select(ParseRole).ToArray();
return new Guild(id, name, iconHash, roles);
}
private Channel ParseChannel(JToken token)
{
// Get basic data
var id = token["id"].Value<string>();
var guildId = token["guild_id"]?.Value<string>();
var type = (ChannelType) token["type"].Value<int>();
var topic = token["topic"]?.Value<string>();
// Extract name based on type
string name;
if (type.IsEither(ChannelType.DirectTextChat, ChannelType.DirectGroupTextChat))
{
guildId = Guild.DirectMessages.Id;
// Try to get name if it's set
name = token["name"]?.Value<string>();
// Otherwise use recipients as the name
if (name.IsBlank())
name = token["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
}
else
{
name = token["name"].Value<string>();
}
return new Channel(id, guildId, name, topic, type);
}
private Message ParseMessage(JToken token)
{
// Get basic data
var id = token["id"].Value<string>();
var channelId = token["channel_id"].Value<string>();
var timeStamp = token["timestamp"].Value<DateTime>();
var editedTimeStamp = token["edited_timestamp"]?.Value<DateTime?>();
var content = token["content"].Value<string>();
var type = (MessageType) token["type"].Value<int>();
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(token["author"]);
// Get attachment
var attachments = new List<Attachment>();
foreach (var attachmentJson in token["attachments"].EmptyIfNull())
{
var attachmentId = attachmentJson["id"].Value<string>();
var attachmentUrl = attachmentJson["url"].Value<string>();
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Other;
var attachmentFileName = attachmentJson["filename"].Value<string>();
var attachmentFileSize = attachmentJson["size"].Value<long>();
var attachment = new Attachment(
attachmentId, attachmentType, attachmentUrl,
attachmentFileName, attachmentFileSize);
attachments.Add(attachment);
}
// Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray();
// Get role mentions
var mentionedRoles = token["mention_roles"]
.Values<string>()
.Select(i => _roleCache.GetOrDefault(i) ?? Role.CreateDeletedRole(id))
.ToArray();
// Get channel mentions
var mentionedChannels = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.Select(i => _channelCache.GetOrDefault(i) ?? Channel.CreateDeletedChannel(id))
.ToArray();
return new Message(id, channelId, type, author, timeStamp, editedTimeStamp, content, attachments,
mentionedUsers, mentionedRoles, mentionedChannels);
}
private async Task<string> GetStringAsync(string url)
{
using (var response = await _httpClient.GetAsync(url))
{
// Check status code
// We throw our own exception here because default one doesn't have status code
if (!response.IsSuccessStatusCode)
throw new HttpErrorStatusCodeException(response.StatusCode);
// Get content
return await response.Content.ReadAsStringAsync();
}
}
public async Task<Guild> GetGuildAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var guild = ParseGuild(JToken.Parse(content));
// Add roles to cache
foreach (var role in guild.Roles)
_roleCache[role.Id] = role;
return guild;
}
public async Task<Channel> GetChannelAsync(string token, string channelId)
{
// Form request url
var url = $"{ApiRoot}/channels/{channelId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channel = ParseChannel(JToken.Parse(content));
// Add channel to cache
_channelCache[channel.Id] = channel;
return channel;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
// Add channels to cache
foreach (var channel in channels)
_channelCache[channel.Id] = channel;
return channels;
}
public async Task<IReadOnlyList<Guild>> GetUserGuildsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
// Get response
var content = await GetStringAsync(url);
// Parse IDs
var guildIds = JArray.Parse(content).Select(t => t["id"].Value<string>());
// Get full guild infos
var guilds = new List<Guild>();
foreach (var guildId in guildIds)
{
var guild = await GetGuildAsync(token, guildId);
guilds.Add(guild);
}
return guilds;
}
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to)
{
var result = new List<Message>();
// We are going backwards from last message to first
// collecting everything between them in batches
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
while (true)
{
// Form request url
var url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
var content = await GetStringAsync(url);
// Parse
var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list
string currentMessageId = null;
foreach (var message in messages)
{
// Break when the message is older than from date
if (from != null && message.TimeStamp < from)
{
currentMessageId = null;
break;
}
// Add message
result.Add(message);
currentMessageId = message.Id;
}
// If no messages - break
if (currentMessageId == null)
break;
// Otherwise offset the next request
beforeId = currentMessageId;
}
// Messages appear newest first, we need to reverse
result.Reverse();
return result;
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_httpClient.Dispose();
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
public partial class DataService
{
private static string DateTimeToSnowflake(DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
}
}
}
@@ -1,208 +0,0 @@
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentHtml(Message message)
{
// A lot of these regexes were inspired by or taken from MarkdownSharp
var content = message.Content;
// HTML-encode content
content = HtmlEncode(content);
// Encode multiline codeblocks (```text```)
content = Regex.Replace(content,
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
m => $"\x1AM{Base64Encode(m.Groups[1].Value)}\x1AM");
// Encode inline codeblocks (`text`)
content = Regex.Replace(content,
@"`([^`]+)`",
m => $"\x1AI{Base64Encode(m.Groups[1].Value)}\x1AI");
// Encode URLs
content = Regex.Replace(content,
@"((https?|ftp)://[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\]\(\);]*[-a-zA-Z0-9+&@#/%=~_|\[\])])(?=$|\W)",
m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL");
// Process bold (**text**)
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
// Process underline (__text__)
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
// Process italic (*text* or _text_)
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
// Process strike through (~~text~~)
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
// Decode and process multiline codeblocks
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
m => $"<div class=\"pre\">{Base64Decode(m.Groups[1].Value)}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre\">{Base64Decode(m.Groups[1].Value)}</span>");
// Decode and process URLs
content = Regex.Replace(content, "\x1AL(.*?)\x1AL",
m => $"<a href=\"{Base64Decode(m.Groups[1].Value)}\">{Base64Decode(m.Groups[1].Value)}</a>");
// New lines
content = content.Replace("\n", "<br />");
// Meta mentions (@everyone)
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
// Meta mentions (@here)
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
{
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css)
{
// Generation info
await output.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Html start
await output.WriteLineAsync("<!DOCTYPE html>");
await output.WriteLineAsync("<html lang=\"en\">");
// HEAD
await output.WriteLineAsync("<head>");
await output.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
await output.WriteLineAsync("<meta charset=\"utf-8\" />");
await output.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await output.WriteLineAsync($"<style>{css}</style>");
await output.WriteLineAsync("</head>");
// Body start
await output.WriteLineAsync("<body>");
// Guild and channel info
await output.WriteLineAsync("<div id=\"info\">");
await output.WriteLineAsync("<div class=\"info-left\">");
await output.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await output.WriteLineAsync("</div>"); // info-left
await output.WriteLineAsync("<div class=\"info-right\">");
await output.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
await output.WriteLineAsync($"<div class=\"channel-topic\">{log.Channel.Topic}</div>");
await output.WriteLineAsync(
$"<div class=\"channel-messagecount\">{log.TotalMessageCount:N0} messages</div>");
await output.WriteLineAsync("</div>"); // info-right
await output.WriteLineAsync("</div>"); // info
// Chat log
await output.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
await output.WriteLineAsync("<div class=\"msg\">");
await output.WriteLineAsync("<div class=\"msg-left\">");
await output.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await output.WriteLineAsync("</div>");
await output.WriteLineAsync("<div class=\"msg-right\">");
await output.WriteAsync(
$"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author.FullName)}\">");
await output.WriteAsync(HtmlEncode(group.Author.Name));
await output.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
await output.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
await output.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContentHtml(message);
await output.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
await output.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await output.WriteLineAsync("</div>"); // msg-content
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.Type == AttachmentType.Image)
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await output.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
else
{
await output.WriteLineAsync("<div class=\"msg-attachment\">");
await output.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await output.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await output.WriteLineAsync("</a>");
await output.WriteLineAsync("</div>");
}
}
}
await output.WriteLineAsync("</div>"); // msg-right
await output.WriteLineAsync("</div>"); // msg
}
await output.WriteLineAsync("</div>"); // log
await output.WriteLineAsync("</body>");
await output.WriteLineAsync("</html>");
}
}
}
@@ -1,79 +0,0 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private string FormatMessageContentPlainText(Message message)
{
var content = message.Content;
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id> and <@!id>)
foreach (var mentionedUser in message.MentionedUsers)
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private async Task ExportAsPlainTextAsync(ChannelChatLog log, TextWriter output)
{
// Generation info
await output.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
await output.WriteLineAsync();
// Guild and channel info
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync($"Guild: {log.Guild.Name}");
await output.WriteLineAsync($"Channel: {log.Channel.Name}");
await output.WriteLineAsync($"Topic: {log.Channel.Topic}");
await output.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
await output.WriteLineAsync('='.Repeat(48));
await output.WriteLineAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat);
await output.WriteLineAsync($"{group.Author.FullName} [{timeStampFormatted}]");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var contentFormatted = FormatMessageContentPlainText(message);
await output.WriteLineAsync(contentFormatted);
}
// Attachments
foreach (var attachment in message.Attachments)
{
await output.WriteLineAsync(attachment.Url);
}
}
await output.WriteLineAsync();
}
}
}
}
@@ -1,81 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService : IExportService
{
private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
public async Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log)
{
using (var output = File.CreateText(filePath))
{
if (format == ExportFormat.PlainText)
{
await ExportAsPlainTextAsync(log, output);
}
else if (format == ExportFormat.HtmlDark)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else if (format == ExportFormat.HtmlLight)
{
var css = Assembly.GetExecutingAssembly()
.GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css");
await ExportAsHtmlAsync(log, output, css);
}
else throw new ArgumentOutOfRangeException(nameof(format));
}
}
}
public partial class ExportService
{
private static string Base64Encode(string str)
{
return str.GetBytes().ToBase64();
}
private static string Base64Decode(string str)
{
return str.FromBase64().GetString();
}
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
var unit = 0;
while (size >= 1024)
{
size /= 1024;
++unit;
}
return $"{size:0.#} {units[unit]}";
}
}
}

Some files were not shown because too many files have changed in this diff Show More