Compare commits

...

161 Commits

Author SHA1 Message Date
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
Alexey Golub cc2583f96b Update version 2018-04-08 13:03:03 +03:00
Alexey Golub de763f8aaa Try to parse DM group chat names instead of only using participants
Fixes #41
2018-04-08 12:46:55 +03:00
Alexey Golub 7b67cbc385 Improve markdown parsing to make it less volatile
Fixes #18, #20, #40
2018-04-08 12:29:38 +03:00
Alexey Golub 7ffb799136 Update nuget packages 2018-04-01 00:31:58 +03:00
Alexey Golub 5a84fb25e6 Update nuget packages and rework UpdateService 2018-03-20 22:18:09 +02:00
Alexey Golub 9988300942 Let user disable auto-updates 2018-02-25 18:22:55 +02:00
Alexey Golub 4540134a98 Don't crash when update fails 2018-02-25 18:11:00 +02:00
Alexey Golub 656e5a5b0d Remove some views and replace them with snackbar notifications 2018-02-25 17:17:50 +02:00
Alexey Golub 63e3ba8891 Add application icon to CLI 2018-02-25 17:03:08 +02:00
Alexey Golub d7572d9fd3 Update nuget packages 2018-02-25 17:01:52 +02:00
Alexey Golub 63c835df88 Implement auto-update via Onova 2018-02-25 17:00:44 +02:00
Alexey Golub 7bfd645e8e Update appveyor.yml 2018-02-11 12:38:59 +02:00
Alexey Golub 4e5d3cb4ed Add changelog 2018-02-11 12:38:48 +02:00
Alexey Golub cff976e2e4 Update nuget packages 2018-02-10 18:29:31 +02:00
Alexey Golub 0c69fd6e45 Update appveyor.yml 2018-02-10 18:27:04 +02:00
Alexey Golub 909a58b641 Update deploy script 2018-02-10 18:25:49 +02:00
Alexey Golub 2db4ad46cc Remove Costura.Fody 2018-02-10 18:25:34 +02:00
Alexey Golub ffa1023c50 Improve word wrapping in messages
Doesn't break words. Fixes #31.
2018-02-10 18:02:36 +02:00
Alexey Golub f7ef3bbb44 Bump framework version and small cleanup 2018-01-26 18:13:25 +02:00
Alexey Golub bb2b04d2e5 Add IsDefault and IsCancel for all dialog buttons 2018-01-13 18:15:42 +02:00
Alexey Golub 1f36fb608a Improve CLI help output 2018-01-12 23:29:16 +02:00
Alexey Golub b6e93e5770 Update readme 2018-01-12 21:33:32 +02:00
Alexey Golub 8515efe11b Add command line interface and change solution structure (#26) 2018-01-12 20:28:36 +01:00
Oleksii Holub 7da82f9ef4 Fix crash on channels without topic 2018-01-10 18:28:42 +02:00
Alexey Golub 6ccba43807 Update copyright 2017-12-31 14:12:35 +02:00
Oleksii Holub 2ce9e2c77e Code styling 2017-12-22 16:29:50 +02:00
Oleksii Holub a6a54d688f Deploy on tags 2017-12-19 18:18:00 +02:00
Oleksii Holub e0d2f9c1ba Minor comment fixes 2017-12-19 18:17:42 +02:00
Alexey Golub 01f8cf0e32 Skip tags in appveyor 2017-12-12 14:04:29 +02:00
Alexey Golub ebab80fda1 Update appveyor.yml 2017-12-10 22:41:04 +02:00
Alexey Golub c432a75f62 Simplify deployment script 2017-12-10 22:36:16 +02:00
Alexey Golub be4989ea34 Add icon 2017-11-25 19:06:08 +02:00
Oleksii Holub f8dae72411 Refactor 2017-11-09 17:57:32 +02:00
Oleksii Holub ab46361489 Add channel topic to output
Closes #22
2017-11-09 17:55:04 +02:00
Alexey Golub 4a986fe5df Refactor 2017-10-29 13:09:57 +02:00
Alexey Golub e3d2afac87 Update readme 2017-10-27 22:51:07 +03:00
Alexey Golub 0cfcaca6b2 Add appveyor.yml 2017-10-27 22:46:10 +03:00
Alexey Golub 107c9de265 Add message type to message object 2017-10-27 22:42:12 +03:00
Alexey Golub 5d6edfd937 Update version 2017-10-27 22:36:25 +03:00
Alexey Golub 60a5a69aa4 Improve message date filtering, convert from snowflake to get the 'to' message ID 2017-10-27 22:35:04 +03:00
Alexey Golub cc565598d3 Add support for channel mentions formatting
Closes #19
2017-10-27 22:08:53 +03:00
Alexey Golub 6a7000cbc9 Re-order URL formatting in HTML formatter
Slightly helps #18
2017-10-27 21:34:45 +03:00
Alexey Golub 6706ff95f1 Fix user mentions not resolving on bots 2017-10-27 21:07:54 +03:00
Alexey Golub ecc95c9c7f Make the mentions resolving safer 2017-10-27 21:04:10 +03:00
Alexey Golub d4f2049db6 Fix wrapping issues in html export 2017-10-26 23:36:10 +03:00
Alexey Golub 4d5118de76 Add mention support (#17)
* Support for user mentions and incomplete support for role mentions

* Improve formatting a bit

* Implement a hack to get roles
2017-10-26 22:21:44 +02:00
Alexey Golub 171718989a Add workarounds for non-default message types
Fixes #16
2017-10-26 22:14:50 +03:00
Alexey Golub 75e8d59cc3 Wrap text in inline pre 2017-10-25 23:37:18 +03:00
Alexey Golub 1d7264a71b Add support for custom emojis
Closes #14
2017-10-25 23:31:32 +03:00
Alexey Golub 2c0a63352c Simplify regexes 2017-10-25 22:19:25 +03:00
Alexey Golub 42d4d64695 Add underscore italics to markdown formatter
Fixes #15
2017-10-25 22:04:47 +03:00
Alexey Golub 6968f987ce Small readability improvement 2017-10-16 20:24:34 +03:00
Alexey Golub 5ecbfd50b8 Update deployment script 2017-10-16 13:00:58 +03:00
Alexey Golub e59a1ea8b4 Add deployment script 2017-10-16 12:47:55 +03:00
Alexey Golub 540ce7f3c3 Update readme 2017-10-16 00:37:34 +03:00
Alexey Golub a0d261f966 Add fancy badges 2017-10-15 23:58:33 +03:00
Alexey Golub 10446064d5 Ammy formatting 2017-10-07 22:03:43 +03:00
Alexey Golub 6709821324 Cleanup views 2017-10-06 23:27:17 +03:00
Alexey Golub 705e60c76d Update version 2017-10-02 19:24:12 +03:00
Alexey Golub 8ac8c16db3 Disable whole UI when busy 2017-10-02 19:16:06 +03:00
Alexey Golub 1850caba71 Add 'optional' suffix for from/to date pickers in ExportSetupDialog 2017-10-02 19:12:00 +03:00
Alexey Golub 6ccaf3f106 Fix settings not loading early enough 2017-10-02 19:09:35 +03:00
Alexey Golub d7345e91d3 Improve UX of ExportSetupDialog
Closes #11
2017-10-02 18:59:00 +03:00
Alexey Golub e5e9d4c9ff Tidy up 2017-10-01 12:18:54 +03:00
Alexey Golub b280f8b736 Add AM/PM to default date format as it was previously missing it 2017-10-01 11:27:21 +03:00
Alexey Golub ca452d2421 Update readme 2017-10-01 01:56:42 +03:00
Alexey Golub 9e7d4033e0 Update version 2017-10-01 01:55:04 +03:00
Alexey Golub 263a606bff Improve error messages 2017-10-01 01:49:39 +03:00
Alexey Golub 1e853f11b0 Fix styling for pre 2017-10-01 01:49:26 +03:00
Alexey Golub 77d0956f5d Redesign file picker in ExportSetupDialog 2017-10-01 00:32:10 +03:00
Alexey Golub d251e8ecf6 Update readme 2017-09-30 23:14:32 +03:00
Alexey Golub 01f0e6952d Add from/to date pickers
Fixes #6
2017-09-30 22:57:52 +03:00
Alexey Golub 28a67ab60d Add export dialog and implement export as plain text 2017-09-30 22:22:28 +03:00
Alexey Golub 42bb4e72d2 Remove HtmlAgilityPack from readme 2017-09-30 17:27:09 +03:00
Alexey Golub a79d72f975 Update version 2017-09-30 17:25:20 +03:00
Alexey Golub b110413c08 Fix div.pre output styling 2017-09-30 17:24:46 +03:00
Alexey Golub a556926fe6 Rework HTML export
Fixes #8
2017-09-30 17:18:41 +03:00
Alexey Golub 5a57b4a6b1 Generalize HTTP exceptions 2017-09-29 20:24:02 +03:00
Alexey Golub d3b311b88f Update readme screenshot link 2017-09-28 23:07:27 +03:00
Alexey Golub d8db4fca90 ExportService is pseudo async now 2017-09-28 22:48:24 +03:00
Alexey Golub b7979d6d1f Fix message count in exported file 2017-09-28 22:45:25 +03:00
Alexey Golub 749a55a30e Round guild icons 2017-09-28 22:32:29 +03:00
Alexey Golub 7b9de314ab Refactor message grouping out and add a limit setting 2017-09-28 22:16:38 +03:00
Alexey Golub 69184a74fe Add support for custom date formats in settings 2017-09-28 22:05:16 +03:00
Alexey Golub efae0e99b8 Add a dialog that shows when export completes 2017-09-28 21:57:57 +03:00
Alexey Golub 8afe9852fb Add some error handling 2017-09-28 21:36:17 +03:00
Alexey Golub f2178a0445 Refactor 2017-09-28 20:51:32 +03:00
Alexey Golub c01b9d0e5f Don't disable everything when busy 2017-09-28 20:21:00 +03:00
Alexey Golub 151c5e2f23 Remove mentions of wiki from readme, add other libraries used 2017-09-28 01:28:10 +03:00
Alexey Golub 6d7a8ae063 Add GUI (#7)
* Create a dummy WPF project

* Set up Ammy placeholders

* Don't track autogenerated files

* Basic layout

* Add Program.cs

* Implement basic workflow

* Autofocus token textbox and add Enter key handler

* Strip double quotes from token

* AmmyUI converters are slightly dumb :(

* Use CanExecute

* Add file path select and theme select, also refactor

* Persist token

* Trying to improve UI/UX - 1

* Rename stuff

* Finish improving UI/UX

* Remove data placeholder

* Remove border on middle grid

* Ok now i'm done

* Improve Discord API layer

* Add lots of stuff

* Show filesizes in export

* Improve export

* Animations

* Update readme

* Improving gui again

* Improve UI again

* Refactor
2017-09-28 01:24:45 +03:00
Alexey Golub d8bbe8c8c8 Update Readme.md 2017-09-13 18:48:43 +03:00
Alexey Golub 9a1a257f1e Update Readme.md 2017-09-11 20:31:51 +03:00
Alexey Golub 5e0a39df5e Small cleanup 2017-09-09 22:22:15 +03:00
Alexey Golub fb3b14f1ce Use var 2017-09-01 21:16:18 +03:00
Alexey Golub 34afce2a66 Make Dark the default theme
Light is burning my eyes
2017-09-01 20:04:40 +03:00
Alexey Golub 95d581797b Add dark theme and theme switch (#4)
* Add dark theme

* Refactor a bit

* Add back Light theme and add a switch

* Update readme
2017-09-01 15:45:19 +03:00
Alexey Golub 22d7dd43ec Refactor project file 2017-08-29 20:23:17 +03:00
Alexey Golub dd942824ec Improve token extraction steps 2017-08-29 20:02:08 +03:00
Alexey Golub 0974dd77bf Add download info to readme 2017-08-29 00:45:04 +03:00
101 changed files with 4696 additions and 688 deletions
+63
View File
@@ -0,0 +1,63 @@
### 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.
- Added a command line interface alternative to the current graphical user interface.
- Implemented application auto-update (can be disabled in settings).
- Improved some GUI elements and replaced some dialogs with notifications.
- Fixed a lot of issues with the markdown processor.
- Fixed DM group chats that have a custom name being shown as blank in the list.
### v2.3 (27-Oct-2017)
- Improved message date filtering, it's now marginally faster.
- Fixed underscores not recognized as italics in markdown.
- Added support for custom emojis.
- Added support for user and role mentions.
- Added support for channel mentions.
- Fixed text in pre blocks not being wrapped correctly.
- Added workaround for non-default message types.
+11
View File
@@ -0,0 +1,11 @@
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
# GUI
$files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
# CLI
$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
+26
View File
@@ -0,0 +1,26 @@
using CommonServiceLocator;
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight.Ioc;
namespace DiscordChatExporter.Cli
{
public class Container
{
public Container()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
}
public T Resolve<T>(string key = null)
{
return ServiceLocator.Current.GetInstance<T>(key);
}
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<Version>2.8</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.2.1" />
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
</Project>
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.Linq;
using CommandLine;
using DiscordChatExporter.Cli.Verbs;
using DiscordChatExporter.Cli.Verbs.Options;
namespace DiscordChatExporter.Cli
{
public static class Program
{
private static void PrintTokenHelp()
{
Console.WriteLine("# To get user token:");
Console.WriteLine(" 1. Open Discord app");
Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 4. Press Ctrl+R to trigger reload");
Console.WriteLine(" 5. Navigate to the Application tab");
Console.WriteLine(" 6. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 7. Find \"token\" under key and copy the value");
Console.WriteLine();
Console.WriteLine("# To get bot token:");
Console.WriteLine(" 1. Go to Discord developer portal");
Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine(" 3. Open your application's settings");
Console.WriteLine(" 4. Navigate to the Bot section on the left");
Console.WriteLine(" 5. Under Token click Copy");
Console.WriteLine();
Console.WriteLine("# To get guild or channel ID:");
Console.WriteLine(" 1. Open Discord app");
Console.WriteLine(" 2. Log in if you haven't");
Console.WriteLine(" 3. Open Settings");
Console.WriteLine(" 4. Go to Appearance section");
Console.WriteLine(" 5. Enable Developer Mode");
Console.WriteLine(" 6. Right click on the desired guild or channel and click Copy ID");
}
public static void Main(string[] args)
{
// Get all verb types
var verbTypes = new[]
{
typeof(ExportChatOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions)
};
// Parse command line arguments
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
// Execute commands
parsedArgs.WithParsed<ExportChatOptions>(o => new ExportChatVerb(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());
// Show token help if help requested or no verb specified
parsedArgs.WithNotParsed(errs =>
{
var err = errs.First();
if (err.Tag == ErrorType.NoVerbSelectedError)
PrintTokenHelp();
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
PrintTokenHelp();
});
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportChatVerb : Verb<ExportChatOptions>
{
public ExportChatVerb(ExportChatOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var container = new Container();
var settingsService = container.Resolve<ISettingsService>();
var dataService = container.Resolve<IDataService>();
var exportService = container.Resolve<IExportService>();
// Configure settings
if (Options.DateFormat.IsNotBlank())
settingsService.DateFormat = Options.DateFormat;
if (Options.MessageGroupLimit > 0)
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
Options.After, Options.Before);
// Generate file path if not set
var filePath = Options.FilePath;
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
{
filePath += $"{chatLog.Guild.Name} - {chatLog.Channel.Name}.{Options.ExportFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
}
// Export
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
// Print result
Console.WriteLine($"Exported chat to [{filePath}]");
}
}
}
@@ -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;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class GetChannelsVerb : Verb<GetChannelsOptions>
{
public GetChannelsVerb(GetChannelsOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get data service
var container = new Container();
var dataService = container.Resolve<IDataService>();
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Print result
foreach (var channel in channels.Where(c => c.Type.IsEither(ChannelType.GuildTextChat))
.OrderBy(c => c.Name))
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,30 @@
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 container = new Container();
var dataService = container.Resolve<IDataService>();
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Print result
foreach (var channel in channels.OrderBy(c => c.Name))
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
}
@@ -0,0 +1,30 @@
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 container = new Container();
var dataService = container.Resolve<IDataService>();
// Get guilds
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
// Print result
foreach (var guild in guilds.OrderBy(g => g.Name))
Console.WriteLine($"{guild.Id} | {guild.Name}");
}
}
}
@@ -0,0 +1,34 @@
using System;
using CommandLine;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("export", HelpText = "Export channel chat log to a file.")]
public class ExportChatOptions : TokenOptions
{
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
public string ChannelId { get; set; }
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
public ExportFormat ExportFormat { get; set; }
[Option('o', "output", Default = null, HelpText = "Output file path.")]
public string FilePath { get; set; }
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
public DateTime? After { get; set; }
[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; }
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
public int MessageGroupLimit { 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 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();
}
}
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
<Version>2.8</Version>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark.html" />
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight.html" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\Core.html" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\Shared.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Html\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportTemplates\Csv.csv" />
</ItemGroup>
<ItemGroup>
<Reference Include="mscorlib" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Onova" Version="2.1.0" />
<PackageReference Include="Polly" Version="6.0.1" />
<PackageReference Include="Scriban" Version="1.2.1" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
</ItemGroup>
</Project>
@@ -0,0 +1,19 @@
using System;
using System.Net;
namespace DiscordChatExporter.Core.Exceptions
{
public class HttpErrorStatusCodeException : Exception
{
public HttpStatusCode StatusCode { get; }
public string ReasonPhrase { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase)
: base($"Error HTTP status code: {statusCode} - {reasonPhrase}")
{
StatusCode = statusCode;
ReasonPhrase = reasonPhrase;
}
}
}
@@ -0,0 +1,23 @@
using System;
using System.Drawing;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Internal
{
internal static class Extensions
{
public static string ToSnowflake(this DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
}
public static string Base64Encode(this string str) => str.GetBytes().ToBase64();
public static string Base64Decode(this string str) => str.FromBase64().GetString();
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
}
}
@@ -0,0 +1,33 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#attachment-object
public class Attachment
{
public string Id { get; }
public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public bool IsImage => Width != null;
public string FileName { get; }
public long FileSize { get; }
public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
{
Id = id;
Url = url;
Width = width;
Height = height;
FileName = fileName;
FileSize = fileSize;
}
public override string ToString() => FileName;
}
}
@@ -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;
}
}
}
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Core.Models
{
public enum AuthTokenType
{
User,
Bot
}
}
@@ -0,0 +1,34 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object
public partial class Channel
{
public string Id { get; }
public string GuildId { get; }
public string Name { get; }
public string Topic { get; }
public ChannelType Type { get; }
public Channel(string id, string guildId, string name, string topic, ChannelType type)
{
Id = id;
GuildId = guildId;
Name = name;
Topic = topic;
Type = type;
}
public override string ToString() => Name;
}
public partial class Channel
{
public static Channel CreateDeletedChannel(string id) =>
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
}
@@ -0,0 +1,13 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
public enum ChannelType
{
GuildTextChat,
DirectTextChat,
GuildVoiceChat,
DirectGroupTextChat,
Category
}
}
@@ -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 DateTime? From { get; }
public DateTime? To { get; }
public IReadOnlyList<Message> Messages { get; }
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
IReadOnlyList<Message> messages, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
From = from;
To = to;
Messages = messages;
Mentionables = mentionables;
}
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
}
}
+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 DateTime? 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, DateTime? 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;
}
}
}
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Core.Models
{
public enum ExportFormat
{
PlainText,
HtmlDark,
HtmlLight,
Csv
}
}
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
public static class Extensions
{
public static string GetFileExtension(this ExportFormat format)
{
if (format == ExportFormat.PlainText)
return "txt";
if (format == ExportFormat.HtmlDark)
return "html";
if (format == ExportFormat.HtmlLight)
return "html";
if (format == ExportFormat.Csv)
return "csv";
throw new ArgumentOutOfRangeException(nameof(format));
}
public static string GetDisplayName(this ExportFormat format)
{
if (format == ExportFormat.PlainText)
return "Plain Text";
if (format == ExportFormat.HtmlDark)
return "HTML (Dark)";
if (format == ExportFormat.HtmlLight)
return "HTML (Light)";
if (format == ExportFormat.Csv)
return "Comma Seperated Values (CSV)";
throw new ArgumentOutOfRangeException(nameof(format));
}
public static IReadOnlyList<ChatLog> SplitIntoPartitions(this ChatLog chatLog, int partitionLimit)
{
// If chat log has fewer messages than the limit - just return chat log in a list
if (chatLog.Messages.Count <= partitionLimit)
return new[] {chatLog};
var result = new List<ChatLog>();
// Loop through messages
var buffer = new List<Message>();
foreach (var message in chatLog.Messages)
{
// Add message to buffer
buffer.Add(message);
// If reached the limit - split and reset buffer
if (buffer.Count >= partitionLimit)
{
// Add to result
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
chatLog.Mentionables);
result.Add(chatLogPartition);
// Reset the buffer instead of clearing to avoid mutations on existing references
buffer = new List<Message>();
}
}
// Add what's remaining in buffer
if (buffer.Any())
{
// Add to result
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
chatLog.Mentionables);
result.Add(chatLogPartition);
}
return result;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
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 => IconHash.IsNotBlank()
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
public Guild(string id, string name, string iconHash)
{
Id = id;
Name = name;
IconHash = iconHash;
}
public override string ToString() => Name;
}
public partial class Guild
{
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);
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object
public class Message
{
public string Id { get; }
public string ChannelId { get; }
public MessageType Type { get; }
public User Author { get; }
public DateTime Timestamp { get; }
public DateTime? 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 Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
DateTime? 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;
Content = content;
Attachments = attachments;
Embeds = embeds;
Reactions = reactions;
MentionedUsers = mentionedUsers;
}
public override string ToString() => Content;
}
}
@@ -0,0 +1,16 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
public enum MessageType
{
Default,
RecipientAdd,
RecipientRemove,
Call,
ChannelNameChange,
ChannelIconChange,
ChannelPinnedMessage,
GuildMemberJoin
}
}
@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#reaction-object
public class Reaction
{
public int Count { get; }
public string EmojiId { get; }
public string EmojiName { get; }
public Reaction(int count, string emojiId, string emojiName)
{
Count = count;
EmojiId = emojiId;
EmojiName = emojiName;
}
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class Role
{
public string Id { get; }
public string Name { get; }
public Role(string id, string name)
{
Id = id;
Name = name;
}
public override string ToString() => Name;
}
public partial class Role
{
public static Role CreateDeletedRole(string id) =>
new Role(id, "deleted-role");
}
}
+61
View File
@@ -0,0 +1,61 @@
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 => $"{Name}#{Discriminator:0000}";
public string DefaultAvatarHash => $"{Discriminator % 5}";
public string AvatarHash { get; }
public bool IsAvatarAnimated =>
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
public string AvatarUrl
{
get
{
// Custom avatar
if (AvatarHash.IsNotBlank())
{
// Animated
if (IsAvatarAnimated)
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/{DefaultAvatarHash}.png";
}
}
public User(string id, int discriminator, string name, string avatarHash)
{
Id = id;
Discriminator = discriminator;
Name = name;
AvatarHash = avatarHash;
}
public override string ToString() => FullName;
}
public partial class User
{
public static User CreateUnknownUser(string id) =>
new User(id, 0, "Unknown", null);
}
}
@@ -0,0 +1,10 @@
Author;Date;Content;Attachments;
{{~ for message in Model.Messages -}}
{{- }}"{{ message.Author.FullName }}";
{{- }}"{{ message.Timestamp | FormatDate }}";
{{- }}"{{ message.Content | FormatContent }}";
{{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}";
{{~ end -}}
Can't render this file because it contains an unexpected character in line 3 and column 9.
@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<style>
{{ StyleSheet }}
</style>
</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 | Format "N0" }} messages</div>
{{~ if Model.From || Model.To ~}}
<div class="info__channel-date-range">
{{~ if Model.From && Model.To ~}}
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
{{~ else if Model.From ~}}
After {{ Model.From | FormatDate | html.escape }}
{{~ else if Model.To ~}}
Before {{ Model.To | 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 }}">{{ group.Author.Name | html.escape }}</span>
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
{{ message.Content | FormatContent }}
{{~ # 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 | FormatFileSize }})
{{~ 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 | FormatColor }})"></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 }}">{{ embed.Title | FormatContent }}</a>
{{~ else ~}}
{{ embed.Title | FormatContent }}
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</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">{{ field.Name | FormatContent }}</div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</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">
<span class="chatlog__reaction-emoji">
{{~ # Custom emoji ~}}
{{~ if reaction.EmojiId ~}}
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
{{~ # Default emoji ~}}
{{~ else ~}}
{{ reaction.EmojiName }}
{{~ end ~}}
</span>
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ end ~}}
</div>
</div>
{{~ end ~}}
</div>
</body>
</html>
@@ -0,0 +1,97 @@
/* === GENERAL === */
body {
background-color: #36393e;
color: #ffffffb3;
}
a {
color: #0096cf;
}
.pre {
background-color: #2f3136;
}
.pre--multiline {
border-color: #282b30;
color: #839496;
}
.mention {
background-color: #738bd71a;
color: #7289da;
}
/* === INFO === */
.info__guild-name {
color: #ffffff;
}
.info__channel-name {
color: #ffffff;
}
.info__channel-topic {
color: #ffffff;
}
/* === CHATLOG === */
.chatlog__message-group {
border-color: #ffffff0a;
}
.chatlog__author-name {
color: #ffffff;
}
.chatlog__timestamp {
color: #ffffff33;
}
.chatlog__edited-timestamp {
color: #ffffff33;
}
.chatlog__embed-content-container {
background-color: #2e30364d;
border-color: #2e303699;
}
.chatlog__embed-author-name {
color: #ffffff;
}
.chatlog__embed-author-name-link {
color: #ffffff;
}
.chatlog__embed-title {
color: #ffffff;
}
.chatlog__embed-description {
color: #ffffff99;
}
.chatlog__embed-field-name {
color: #ffffff;
}
.chatlog__embed-field-value {
color: #ffffff99;
}
.chatlog__embed-footer {
color: #ffffff99;
}
.chatlog__reaction {
background-color: #ffffff0a;
}
.chatlog__reaction-count {
color: #ffffff4d;
}
@@ -0,0 +1,97 @@
/* === GENERAL === */
body {
background-color: #ffffff;
color: #737f8d;
}
a {
color: #00b0f4;
}
.pre {
background-color: #f9f9f9;
}
.pre--multiline {
border-color: #f3f3f3;
color: #657b83;
}
.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: #f9f9f94d;
border-color: #cccccc4d;
}
.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: #4f535b99;
}
.chatlog__reaction {
background-color: #4f545c0f;
}
.chatlog__reaction-count {
color: #99aab5;
}
@@ -0,0 +1,303 @@
/* === GENERAL === */
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;
}
.pre {
font-family: "Consolas", "Courier New", Courier, Monospace;
white-space: pre-wrap;
}
.pre--multiline {
margin-top: 4px;
padding: 8px;
border: 2px solid;
border-radius: 5px;
}
.pre--inline {
padding: 2px;
border-radius: 3px;
}
.emoji {
width: 24px;
height: 24px;
margin: 0 1px;
vertical-align: middle;
}
.emoji--small {
width: 16px;
height: 16px;
}
.emoji--large {
width: 32px;
height: 32px;
}
.mention {
font-weight: 600;
}
/* === 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%;
}
.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;
}
.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: 5px;
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: 600;
font-size: .75em;
}
.chatlog__reactions {
display: flex;
}
.chatlog__reaction {
margin: 6px 2px 2px 2px;
padding: 2px 6px 2px 2px;
border-radius: 3px;
}
.chatlog__reaction-emoji {
margin-left: 3px;
vertical-align: middle;
}
.chatlog__reaction-count {
font-size: .875em;
vertical-align: middle;
}
@@ -0,0 +1,7 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.DarkTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,7 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.LightTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -0,0 +1,21 @@
{{~ # Info ~}}
==============================================================
Guild: {{ Model.Guild.Name }}
Channel: {{ Model.Channel.Name }}
Topic: {{ Model.Channel.Topic }}
Messages: {{ Model.Messages | array.size | Format "N0" }}
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
==============================================================
{{~ # Log ~}}
{{~ for message in Model.Messages ~}}
{{~ # Author name and timestamp ~}}
{{~ }}[{{ message.Timestamp | FormatDate }}] {{ message.Author.FullName }}
{{~ # Content ~}}
{{~ message.Content | FormatContent }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}
@@ -0,0 +1,196 @@
using System;
using System.Drawing;
using System.Linq;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
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>();
return new User(id, discriminator, name, avatarHash);
}
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 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.IsBlank())
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.IsBlank())
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
return new Channel(id, 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 fileSize = json["size"].Value<long>();
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>();
// 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 Reaction ParseReaction(JToken json)
{
var count = json["count"].Value<int>();
var emojiId = json["emoji"]["id"]?.Value<string>();
var emojiName = json["emoji"]["name"].Value<string>();
return new Reaction(count, emojiId, emojiName);
}
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>();
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
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,249 @@
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.Exceptions;
using DiscordChatExporter.Core.Models;
using Newtonsoft.Json.Linq;
using DiscordChatExporter.Core.Internal;
using Polly;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDataService, IDisposable
{
private readonly HttpClient _httpClient = new HttpClient();
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters)
{
// Create request policy
var policy = Policy
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
// Send request
return await policy.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("=");
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,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
var result = new List<Message>();
// Report indeterminate progress
progress?.Report(-1);
// Get the snowflakes for the selected range
var firstId = from != null ? from.Value.ToSnowflake() : "0";
var lastId = to != null ? to.Value.ToSnowflake() : DateTime.MaxValue.ToSnowflake();
// Get the last message
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
"limit=1", $"before={lastId}");
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 < from)
{
progress?.Report(1);
return result;
}
// Get other messages
var offsetId = firstId;
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,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// Get messages
var messages = await GetChannelMessagesAsync(token, channel.Id, from, to, progress);
// Get mentionables
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
return new ChatLog(guild, channel, from, to, messages, mentionables);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
{
// Get channel
var channel = await GetChannelAsync(token, channelId);
// 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, from, to, progress);
}
public void Dispose()
{
_httpClient.Dispose();
}
}
}
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private 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;
}
}
}
}
@@ -0,0 +1,37 @@
using System.Reflection;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class TemplateLoader : ITemplateLoader
{
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Resources.ExportTemplates";
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
{
return $"{ResourceRootNamespace}.{templateName}";
}
public string GetPath(ExportFormat format)
{
return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}";
}
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
}
public string Load(ExportFormat format)
{
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
}
}
}
}
@@ -0,0 +1,367 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService
{
private class TemplateModel
{
private readonly ExportFormat _format;
private readonly ChatLog _log;
private readonly string _dateFormat;
private readonly int _messageGroupLimit;
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat, int messageGroupLimit)
{
_format = format;
_log = log;
_dateFormat = dateFormat;
_messageGroupLimit = messageGroupLimit;
}
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
// Group adjacent messages by timestamp and author
var buffer = new List<Message>();
foreach (var message in messages)
{
var groupFirst = buffer.FirstOrDefault();
// Group break condition
var breakCondition =
groupFirst != null &&
(
message.Author.Id != groupFirst.Author.Id ||
(message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
buffer.Count >= _messageGroupLimit
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
// Reset the buffer instead of clearing to avoid mutations on existing references
buffer = new List<Message>();
yield return group;
}
// Add message to buffer
buffer.Add(message);
}
// Add what's remaining in buffer
if (buffer.Any())
{
var groupFirst = buffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
yield return group;
}
}
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
private string Format(IFormattable obj, string format) =>
obj.ToString(format, CultureInfo.InvariantCulture);
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
private 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]}";
}
private string FormatColor(Color color)
{
return $"{color.R},{color.G},{color.B},{color.A}";
}
private string FormatContentPlainText(string content)
{
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private string FormatContentHtml(string content, bool allowLinks = false)
{
// HTML-encode content
content = HtmlEncode(content);
// Encode multiline codeblocks (```text```)
content = Regex.Replace(content,
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM");
// Encode inline codeblocks (`text`)
content = Regex.Replace(content,
@"`([^`]+)`",
m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI");
// Encode links
if (allowLinks)
{
content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)",
m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL");
}
// Encode URLs
content = Regex.Replace(content,
@"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))",
m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU");
// 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 pre--multiline\">{m.Groups[1].Value.Base64Decode()}</div>");
// Decode and process inline codeblocks
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
m => $"<span class=\"pre pre--inline\">{m.Groups[1].Value.Base64Decode()}</span>");
// Decode and process links
if (allowLinks)
{
content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
m => $"<a href=\"{m.Groups[2].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
}
// Decode and process URLs
content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
m => $"<a href=\"{m.Groups[1].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
// Process 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>)
var mentionedUserIds = Regex.Matches(content, "&lt;@!?(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"&lt;@!?{mentionedUserId}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "&lt;#(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"&lt;#{mentionedChannelId}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "&lt;@&amp;(\\d+)&gt;")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"&lt;@&amp;{mentionedRoleId}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
var isJumboable = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;", "").IsBlank();
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
$"<img class=\"{emojiClass}\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
private string FormatContentCsv(string content)
{
// Escape quotes
content = content.Replace("\"", "\"\"");
// Escape commas and semicolons
if (content.Contains(",") || content.Contains(";"))
content = $"\"{content}\"";
// User mentions (<@id> and <@!id>)
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedUserId in mentionedUserIds)
{
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
}
// Channel mentions (<#id>)
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedChannelId in mentionedChannelIds)
{
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
}
// Role mentions (<@&id>)
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.ToArray();
foreach (var mentionedRoleId in mentionedRoleIds)
{
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private string FormatContent(string content, bool allowLinks = false)
{
if (_format == ExportFormat.PlainText)
return FormatContentPlainText(content);
if (_format == ExportFormat.HtmlDark)
return FormatContentHtml(content, allowLinks);
if (_format == ExportFormat.HtmlLight)
return FormatContentHtml(content, allowLinks);
if (_format == ExportFormat.Csv)
return FormatContentCsv(content);
throw new ArgumentOutOfRangeException(nameof(_format));
}
public ScriptObject GetScriptObject()
{
// Create instance
var scriptObject = new ScriptObject();
// Import chat log
scriptObject.SetValue("Model", _log, true);
// Import functions
scriptObject.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
scriptObject.Import(nameof(FormatColor), new Func<Color, string>(FormatColor));
scriptObject.Import(nameof(FormatContent), new Func<string, bool, string>(FormatContent));
return scriptObject;
}
}
}
}
@@ -0,0 +1,102 @@
using System.Collections.Generic;
using System.IO;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class ExportService : IExportService
{
private readonly ISettingsService _settingsService;
public ExportService(ISettingsService settingsService)
{
_settingsService = settingsService;
}
private void ExportChatLogSingle(ChatLog chatLog, string filePath, ExportFormat format)
{
// Create template loader
var loader = new TemplateLoader();
// Get template
var templateCode = loader.Load(format);
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 templateModel = new TemplateModel(format, chatLog,
_settingsService.DateFormat, _settingsService.MessageGroupLimit);
context.PushGlobal(templateModel.GetScriptObject());
// Create directory
var dirPath = Path.GetDirectoryName(filePath);
if (dirPath.IsNotBlank())
Directory.CreateDirectory(dirPath);
// Render output
using (var output = File.CreateText(filePath))
{
// Configure output
context.PushOutput(new TextWriterOutput(output));
// Render output
context.Evaluate(template.Page);
}
}
private void ExportChatLogPartitions(IReadOnlyList<ChatLog> partitions, string filePath, ExportFormat format)
{
// 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}{fileExt}";
// Compose full file path
if (dirPath.IsNotBlank())
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
// Export
ExportChatLogSingle(partition, partitionFilePath, format);
// Increment partition number
partitionNumber++;
}
}
public void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
int? partitionLimit = null)
{
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
if (partitionLimit == null || chatLog.Messages.Count <= partitionLimit)
{
ExportChatLogSingle(chatLog, filePath, format);
}
// Otherwise split into partitions and export separately
else
{
var partitions = chatLog.SplitIntoPartitions(partitionLimit.Value);
ExportChatLogPartitions(partitions, filePath, format);
}
}
}
}
@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IDataService
{
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
IEnumerable<Message> messages);
Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
}
}
@@ -0,0 +1,10 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IExportService
{
void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
int? partitionLimit = null);
}
}
@@ -0,0 +1,19 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface ISettingsService
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
AuthToken LastToken { get; set; }
ExportFormat LastExportFormat { get; set; }
int? LastPartitionLimit { get; set; }
void Load();
void Save();
}
}
@@ -0,0 +1,14 @@
using System;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Services
{
public interface IUpdateService
{
bool NeedRestart { get; set; }
Task<Version> CheckPrepareUpdateAsync();
void FinalizeUpdate();
}
}
@@ -0,0 +1,24 @@
using DiscordChatExporter.Core.Models;
using Tyrrrz.Settings;
namespace DiscordChatExporter.Core.Services
{
public class SettingsService : SettingsManager, ISettingsService
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20;
public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public int? LastPartitionLimit { get; set; }
public SettingsService()
{
Configuration.StorageSpace = StorageSpace.Instance;
Configuration.SubDirectoryPath = "";
Configuration.FileName = "Settings.dat";
}
}
}
@@ -0,0 +1,63 @@
using System;
using System.Threading.Tasks;
using Onova;
using Onova.Services;
namespace DiscordChatExporter.Core.Services
{
public class UpdateService : IUpdateService
{
private readonly ISettingsService _settingsService;
private readonly IUpdateManager _manager;
private Version _updateVersion;
private bool _updateFinalized;
public bool NeedRestart { get; set; }
public UpdateService(ISettingsService settingsService)
{
_settingsService = settingsService;
_manager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
}
public async Task<Version> CheckPrepareUpdateAsync()
{
// If auto-update is disabled - don't check for updates
if (!_settingsService.IsAutoUpdateEnabled)
return null;
// Cleanup leftover files
_manager.Cleanup();
// Check for updates
var check = await _manager.CheckForUpdatesAsync();
if (!check.CanUpdate)
return null;
// Prepare the update
if (!_manager.IsUpdatePrepared(check.LastVersion))
await _manager.PrepareUpdateAsync(check.LastVersion);
return _updateVersion = check.LastVersion;
}
public void FinalizeUpdate()
{
// Check if an update is pending
if (_updateVersion == null)
return;
// Check if the update has already been finalized
if (_updateFinalized)
return;
// Launch the updater
_manager.LaunchUpdater(_updateVersion, NeedRestart);
_updateFinalized = true;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
<Application
x:Class="DiscordChatExporter.Gui.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
DispatcherUnhandledException="App_OnDispatcherUnhandledException"
StartupUri="Views/MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
<ResourceDictionary Source="pack://application:,,,/Tyrrrz.WpfExtensions;component/ConvertersDictionary.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Colors -->
<Color x:Key="PrimaryColor">#343838</Color>
<Color x:Key="PrimaryLightColor">#5E6262</Color>
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
<Color x:Key="AccentColor">#F9A825</Color>
<Color x:Key="AccentDarkColor">#C17900</Color>
<Color x:Key="TextColor">#000000</Color>
<Color x:Key="InverseTextColor">#FFFFFF</Color>
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="PrimaryTextBrush"
Opacity="0.87"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="SecondaryTextBrush"
Opacity="0.64"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="DimTextBrush"
Opacity="0.45"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="PrimaryInverseTextBrush"
Opacity="1"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="SecondaryInverseTextBrush"
Opacity="0.7"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="DimInverseTextBrush"
Opacity="0.52"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="AccentTextBrush"
Opacity="1"
Color="{DynamicResource AccentColor}" />
<SolidColorBrush
x:Key="AccentDarkTextBrush"
Opacity="1"
Color="{DynamicResource AccentDarkColor}" />
<SolidColorBrush
x:Key="DividerBrush"
Opacity="0.12"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="InverseDividerBrush"
Opacity="0.12"
Color="{DynamicResource InverseTextColor}" />
<!-- Styles -->
<Style TargetType="{x:Type Image}">
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
<Setter Property="RenderOptions.EdgeMode" Value="Aliased" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
<Setter Property="Height" Value="2" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Minimum" Value="0" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style
x:Key="MaterialDesignFlatActionToggleButton"
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
TargetType="{x:Type ToggleButton}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
</Trigger>
</Style.Triggers>
</Style>
<!-- Converters -->
<converters:ExportFormatToStringConverter x:Key="ExportFormatToStringConverter" />
<!-- Container -->
<local:Container x:Key="Container" />
</ResourceDictionary>
</Application.Resources>
</Application>
+13
View File
@@ -0,0 +1,13 @@
using System.Windows;
using System.Windows.Threading;
namespace DiscordChatExporter.Gui
{
public partial class App
{
private void App_OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs args)
{
MessageBox.Show(args.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
+36
View File
@@ -0,0 +1,36 @@
using CommonServiceLocator;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.ViewModels;
using GalaSoft.MvvmLight.Ioc;
namespace DiscordChatExporter.Gui
{
public class Container
{
public IExportSetupViewModel ExportSetupViewModel => Resolve<IExportSetupViewModel>();
public IMainViewModel MainViewModel => Resolve<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => Resolve<ISettingsViewModel>();
public Container()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
// View models
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
}
private T Resolve<T>(string key = null)
{
return ServiceLocator.Current.GetInstance<T>(key);
}
}
}
@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using System.Windows.Data;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Gui.Converters
{
[ValueConversion(typeof(ExportFormat), typeof(string))]
public class ExportFormatToStringConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var format = (ExportFormat) value;
return format.GetDisplayName();
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{732A67AF-93DE-49DF-B10F-FD74710B7863}</ProjectGuid>
<OutputType>WinExe</OutputType>
<RootNamespace>DiscordChatExporter.Gui</RootNamespace>
<AssemblyName>DiscordChatExporter</AssemblyName>
<TargetFrameworkVersion>v4.6.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<WarningLevel>4</WarningLevel>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<StartupObject />
</PropertyGroup>
<ItemGroup>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xaml">
<RequiredTargetFramework>4.0</RequiredTargetFramework>
</Reference>
<Reference Include="Tyrrrz.Settings, Version=1.3.2.0, Culture=neutral, PublicKeyToken=null" />
<Reference Include="WindowsBase" />
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
</ItemGroup>
<ItemGroup>
<Compile Include="App.xaml.cs">
<DependentUpon>App.xaml</DependentUpon>
</Compile>
<Compile Include="Converters\ExportFormatToStringConverter.cs" />
<Compile Include="Messages\ShowExportSetupMessage.cs" />
<Compile Include="Messages\ShowNotificationMessage.cs" />
<Compile Include="Messages\ShowSettingsMessage.cs" />
<Compile Include="Messages\StartExportMessage.cs" />
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" />
<Compile Include="ViewModels\SettingsViewModel.cs" />
<Compile Include="Container.cs" />
<Compile Include="ViewModels\IMainViewModel.cs" />
<Compile Include="ViewModels\MainViewModel.cs" />
<Compile Include="Views\ExportSetupDialog.xaml.cs">
<DependentUpon>ExportSetupDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Views\MainWindow.xaml.cs">
<DependentUpon>MainWindow.xaml</DependentUpon>
</Compile>
<Compile Include="Views\SettingsDialog.xaml.cs">
<DependentUpon>SettingsDialog.xaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<Compile Include="Properties\AssemblyInfo.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Properties\Resources.Designer.cs">
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<EmbeddedResource Include="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="app.config" />
</ItemGroup>
<ItemGroup>
<Resource Include="..\favicon.ico" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj">
<Project>{707c0cd0-a7e0-4cab-8db9-07a45cb87377}</Project>
<Name>DiscordChatExporter.Core</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="App.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</ApplicationDefinition>
<Page Include="Views\ExportSetupDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\MainWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Views\SettingsDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommonServiceLocator">
<Version>2.0.3</Version>
</PackageReference>
<PackageReference Include="MaterialDesignColors">
<Version>1.1.3</Version>
</PackageReference>
<PackageReference Include="MaterialDesignThemes">
<Version>2.4.0.1044</Version>
</PackageReference>
<PackageReference Include="MvvmLightLibs">
<Version>5.4.1</Version>
</PackageReference>
<PackageReference Include="Tyrrrz.Extensions">
<Version>1.5.1</Version>
</PackageReference>
<PackageReference Include="Tyrrrz.WpfExtensions">
<Version>1.0.5</Version>
</PackageReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
@@ -0,0 +1,17 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Gui.Messages
{
public class ShowExportSetupMessage
{
public Guild Guild { get; }
public Channel Channel { get; }
public ShowExportSetupMessage(Guild guild, Channel channel)
{
Guild = guild;
Channel = channel;
}
}
}
@@ -0,0 +1,25 @@
using System;
namespace DiscordChatExporter.Gui.Messages
{
public class ShowNotificationMessage
{
public string Message { get; }
public string CallbackCaption { get; }
public Action Callback { get; }
public ShowNotificationMessage(string message)
{
Message = message;
}
public ShowNotificationMessage(string message, string callbackCaption, Action callback)
: this(message)
{
CallbackCaption = callbackCaption;
Callback = callback;
}
}
}
@@ -0,0 +1,6 @@
namespace DiscordChatExporter.Gui.Messages
{
public class ShowSettingsMessage
{
}
}
@@ -0,0 +1,31 @@
using System;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Gui.Messages
{
public class StartExportMessage
{
public Channel Channel { get; }
public string FilePath { get; }
public ExportFormat Format { get; }
public DateTime? From { get; }
public DateTime? To { get; }
public int? PartitionLimit { get; }
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to, int? partitionLimit)
{
Channel = channel;
FilePath = filePath;
Format = format;
From = from;
To = to;
PartitionLimit = partitionLimit;
}
}
}
@@ -0,0 +1,7 @@
using System.Reflection;
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017-2018 Alexey Golub")]
[assembly: AssemblyVersion("2.8")]
[assembly: AssemblyFileVersion("2.8")]
+63
View File
@@ -0,0 +1,63 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace DiscordChatExporter.Gui.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "15.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("DiscordChatExporter.Gui.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
}
}
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
{
private readonly ISettingsService _settingsService;
private string _filePath;
private ExportFormat _format;
private DateTime? _from;
private DateTime? _to;
private int? _partitionLimit;
public Guild Guild { get; private set; }
public Channel Channel { get; private set; }
public string FilePath
{
get => _filePath;
set
{
Set(ref _filePath, value);
ExportCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public ExportFormat SelectedFormat
{
get => _format;
set
{
Set(ref _format, value);
// Replace extension in path
var ext = value.GetFileExtension();
if (FilePath != null)
FilePath = Path.ChangeExtension(FilePath, ext);
}
}
public DateTime? From
{
get => _from;
set => Set(ref _from, value);
}
public DateTime? To
{
get => _to;
set => Set(ref _to, value);
}
public int? PartitionLimit
{
get => _partitionLimit;
set => Set(ref _partitionLimit, value);
}
// Commands
public RelayCommand ExportCommand { get; }
public ExportSetupViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
// Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
// Messages
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
{
Guild = m.Guild;
Channel = m.Channel;
SelectedFormat = _settingsService.LastExportFormat;
FilePath = $"{Guild.Name} - {Channel.Name}.{SelectedFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
From = null;
To = null;
PartitionLimit = _settingsService.LastPartitionLimit;
});
}
private void Export()
{
// Persist preferences
_settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimit = PartitionLimit;
// Clamp 'from' and 'to' values
if (From > To)
From = To;
if (To < From)
To = From;
// Start export
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To, PartitionLimit));
}
}
}
@@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.Gui.ViewModels
{
public interface IExportSetupViewModel
{
Guild Guild { get; }
Channel Channel { get; }
string FilePath { get; set; }
IReadOnlyList<ExportFormat> AvailableFormats { get; }
ExportFormat SelectedFormat { get; set; }
DateTime? From { get; set; }
DateTime? To { get; set; }
int? PartitionLimit { get; set; }
RelayCommand ExportCommand { get; }
}
}
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.Gui.ViewModels
{
public interface IMainViewModel
{
bool IsBusy { get; }
bool IsDataAvailable { get; }
bool IsProgressIndeterminate { get; }
double Progress { get; }
bool IsBotToken { get; set; }
string TokenValue { get; set; }
IReadOnlyList<Guild> AvailableGuilds { get; }
Guild SelectedGuild { get; set; }
IReadOnlyList<Channel> AvailableChannels { get; }
RelayCommand ViewLoadedCommand { get; }
RelayCommand ViewClosedCommand { get; }
RelayCommand PullDataCommand { get; }
RelayCommand ShowSettingsCommand { get; }
RelayCommand ShowAboutCommand { get; }
RelayCommand<Channel> ShowExportSetupCommand { get; }
}
}
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.ViewModels
{
public interface ISettingsViewModel
{
bool IsAutoUpdateEnabled { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
}
}
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Windows;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class MainViewModel : ViewModelBase, IMainViewModel
{
private readonly ISettingsService _settingsService;
private readonly IUpdateService _updateService;
private readonly IDataService _dataService;
private readonly IExportService _exportService;
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy;
private double _progress;
private bool _isBotToken;
private string _tokenValue;
private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels;
public bool IsBusy
{
get => _isBusy;
private set
{
Set(ref _isBusy, value);
PullDataCommand.RaiseCanExecuteChanged();
ShowExportSetupCommand.RaiseCanExecuteChanged();
}
}
public bool IsDataAvailable => AvailableGuilds.NotNullAndAny();
public bool IsProgressIndeterminate => Progress <= 0;
public double Progress
{
get => _progress;
private set
{
Set(ref _progress, value);
RaisePropertyChanged(() => IsProgressIndeterminate);
}
}
public bool IsBotToken
{
get => _isBotToken;
set => Set(ref _isBotToken, value);
}
public string TokenValue
{
get => _tokenValue;
set
{
// Remove invalid chars
value = value?.Trim('"');
Set(ref _tokenValue, value);
PullDataCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Guild> AvailableGuilds
{
get => _availableGuilds;
private set
{
Set(ref _availableGuilds, value);
RaisePropertyChanged(() => IsDataAvailable);
}
}
public Guild SelectedGuild
{
get => _selectedGuild;
set
{
Set(ref _selectedGuild, value);
AvailableChannels = value != null ? _guildChannelsMap[value] : Array.Empty<Channel>();
ShowExportSetupCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<Channel> AvailableChannels
{
get => _availableChannels;
private set => Set(ref _availableChannels, value);
}
public RelayCommand ViewLoadedCommand { get; }
public RelayCommand ViewClosedCommand { get; }
public RelayCommand PullDataCommand { get; }
public RelayCommand ShowSettingsCommand { get; }
public RelayCommand ShowAboutCommand { get; }
public RelayCommand<Channel> ShowExportSetupCommand { get; }
public MainViewModel(ISettingsService settingsService, IUpdateService updateService, IDataService dataService,
IExportService exportService)
{
_settingsService = settingsService;
_updateService = updateService;
_dataService = dataService;
_exportService = exportService;
_guildChannelsMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
// Commands
ViewLoadedCommand = new RelayCommand(ViewLoaded);
ViewClosedCommand = new RelayCommand(ViewClosed);
PullDataCommand = new RelayCommand(PullData, () => TokenValue.IsNotBlank() && !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
// Messages
MessengerInstance.Register<StartExportMessage>(this,
m => Export(m.Channel, m.FilePath, m.Format, m.From, m.To, m.PartitionLimit));
}
private async void ViewLoaded()
{
// Load settings
_settingsService.Load();
// Get last token
if (_settingsService.LastToken != null)
{
IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot;
TokenValue = _settingsService.LastToken.Value;
}
// Check and prepare update
try
{
var updateVersion = await _updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
MessengerInstance.Send(new ShowNotificationMessage(
$"Update to DiscordChatExporter v{updateVersion} will be installed when you exit",
"INSTALL NOW", () =>
{
_updateService.NeedRestart = true;
Application.Current.Shutdown();
}));
}
}
catch
{
MessengerInstance.Send(new ShowNotificationMessage("Failed to perform application auto-update"));
}
}
private void ViewClosed()
{
// Save settings
_settingsService.Save();
// Finalize updates if available
_updateService.FinalizeUpdate();
}
private async void PullData()
{
IsBusy = true;
// Create token
var token = new AuthToken(
IsBotToken ? AuthTokenType.Bot : AuthTokenType.User,
TokenValue);
// Save token
_settingsService.LastToken = token;
// Clear existing
_guildChannelsMap.Clear();
try
{
// Get DM channels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = Guild.DirectMessages;
_guildChannelsMap[guild] = channels.OrderBy(c => c.Name).ToArray();
}
// Get guild channels
{
var guilds = await _dataService.GetUserGuildsAsync(token);
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat)
.OrderBy(c => c.Name)
.ToArray();
}
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{
MessengerInstance.Send(new ShowNotificationMessage("Unauthorized make sure the token is valid"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
MessengerInstance.Send(new ShowNotificationMessage("Forbidden account may be locked by 2FA"));
}
AvailableGuilds = _guildChannelsMap.Keys.ToArray();
SelectedGuild = AvailableGuilds.FirstOrDefault();
IsBusy = false;
}
private void ShowSettings()
{
MessengerInstance.Send(new ShowSettingsMessage());
}
private void ShowAbout()
{
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
}
private void ShowExportSetup(Channel channel)
{
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
}
private async void Export(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to, int? partitionLimit)
{
IsBusy = true;
// Get last used token
var token = _settingsService.LastToken;
// Get guild
var guild = SelectedGuild;
// Create progress handler
var progressHandler = new Progress<double>(p => Progress = p);
try
{
// Get chat log
var chatLog = await _dataService.GetChatLogAsync(token, guild, channel, from, to, progressHandler);
// Export
_exportService.ExportChatLog(chatLog, filePath, format, partitionLimit);
// Notify completion
MessengerInstance.Send(new ShowNotificationMessage("Export complete"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel"));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
MessengerInstance.Send(new ShowNotificationMessage("This channel doesn't exist"));
}
Progress = 0;
IsBusy = false;
}
}
}
@@ -0,0 +1,34 @@
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels
{
public class SettingsViewModel : ViewModelBase, ISettingsViewModel
{
private readonly ISettingsService _settingsService;
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value;
}
public string DateFormat
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
}
public int MessageGroupLimit
{
get => _settingsService.MessageGroupLimit;
set => _settingsService.MessageGroupLimit = value.ClampMin(0);
}
public SettingsViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
}
}
}
@@ -0,0 +1,89 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.ExportSetupDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="325"
DataContext="{Binding ExportSetupViewModel, Source={StaticResource Container}}">
<StackPanel>
<!-- File path -->
<TextBox
Margin="16,16,16,8"
materialDesign:HintAssist.Hint="Output file"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
Text="{Binding FilePath, UpdateSourceTrigger=PropertyChanged}" />
<!-- Format -->
<ComboBox
Margin="16,8,16,8"
materialDesign:HintAssist.Hint="Export format"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
ItemsSource="{Binding AvailableFormats}"
SelectedItem="{Binding SelectedFormat}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ExportFormatToStringConverter}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Date limits -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DatePicker
x:Name="FromDatePicker"
Grid.Row="0"
Grid.Column="0"
Margin="16,20,8,8"
materialDesign:HintAssist.Hint="From (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding SelectedDate, ElementName=ToDatePicker}"
SelectedDate="{Binding From}" />
<DatePicker
x:Name="ToDatePicker"
Grid.Row="0"
Grid.Column="1"
Margin="8,20,16,8"
materialDesign:HintAssist.Hint="To (optional)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding SelectedDate, ElementName=FromDatePicker}"
SelectedDate="{Binding To}" />
</Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8,16,8"
materialDesign:HintAssist.Hint="Messages per partition (optional)"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimit, TargetNullValue=''}" />
<!-- Buttons -->
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
x:Name="BrowseButton"
Margin="8"
Click="BrowseButton_Click"
Content="BROWSE"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
x:Name="ExportButton"
Margin="8"
Click="ExportButton_Click"
Command="{Binding ExportCommand}"
Content="EXPORT"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
<Button
Margin="8"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
Content="CANCEL"
IsCancel="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
</StackPanel>
</StackPanel>
</UserControl>
@@ -0,0 +1,44 @@
using System.Windows;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Gui.ViewModels;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
namespace DiscordChatExporter.Gui.Views
{
public partial class ExportSetupDialog
{
private IExportSetupViewModel ViewModel => (IExportSetupViewModel)DataContext;
public ExportSetupDialog()
{
InitializeComponent();
}
public void BrowseButton_Click(object sender, RoutedEventArgs args)
{
// Get file extension of the selected format
var ext = ViewModel.SelectedFormat.GetFileExtension();
// Open dialog
var sfd = new SaveFileDialog
{
FileName = ViewModel.FilePath,
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
AddExtension = true,
Title = "Select output file"
};
// Assign new file path if dialog was successful
if (sfd.ShowDialog() == true)
{
ViewModel.FilePath = sfd.FileName;
}
}
public void ExportButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
}
}
@@ -0,0 +1,285 @@
<Window
x:Class="DiscordChatExporter.Gui.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Title="DiscordChatExporter"
Width="600"
Height="550"
Background="{DynamicResource MaterialDesignPaper}"
DataContext="{Binding MainViewModel, Source={StaticResource Container}}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
FontFamily="{DynamicResource MaterialDesignFont}"
Icon="/DiscordChatExporter;component/favicon.ico"
SnapsToDevicePixels="True"
TextElement.FontSize="13"
TextElement.FontWeight="Regular"
TextElement.Foreground="{DynamicResource SecondaryTextBrush}"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="Auto"
UseLayoutRounding="True"
WindowStartupLocation="CenterScreen">
<i:Interaction.Triggers>
<i:EventTrigger EventName="Loaded">
<i:InvokeCommandAction Command="{Binding ViewLoadedCommand}" />
</i:EventTrigger>
<i:EventTrigger EventName="Closed">
<i:InvokeCommandAction Command="{Binding ViewClosedCommand}" />
</i:EventTrigger>
</i:Interaction.Triggers>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding ElementName=Snackbar}">
<DockPanel>
<!-- Toolbar -->
<Border
Background="{DynamicResource PrimaryHueMidBrush}"
DockPanel.Dock="Top"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<materialDesign:Card
Grid.Row="0"
Grid.Column="0"
Margin="6,6,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token type -->
<ToggleButton
Grid.Column="0"
Margin="6"
IsChecked="{Binding IsBotToken}"
Style="{StaticResource MaterialDesignFlatActionToggleButton}"
ToolTip="Switch between user token and bot token">
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
<!-- Token value -->
<TextBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="2,6,6,7"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontSize="16"
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button -->
<Button
Grid.Column="2"
Margin="0,6,6,6"
Padding="4"
Command="{Binding PullDataCommand}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="ArrowRight" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Menu -->
<materialDesign:PopupBox
Grid.Row="0"
Grid.Column="1"
Foreground="{DynamicResource PrimaryHueMidForegroundBrush}"
PlacementMode="LeftAndAlignTopEdges">
<StackPanel>
<Button Command="{Binding ShowSettingsCommand}" Content="Settings" />
<Button Command="{Binding ShowAboutCommand}" Content="About" />
</StackPanel>
</materialDesign:PopupBox>
</Grid>
<!-- Progress bar -->
<ProgressBar
Background="Transparent"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibilityConverter}}"
Value="{Binding Progress, Mode=OneWay}" />
</StackPanel>
</Border>
<!-- Content -->
<Grid>
<DockPanel
Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={StaticResource InvertBoolConverter}}"
Visibility="{Binding IsDataAvailable, Converter={StaticResource BoolToVisibilityConverter}}">
<!-- Guilds -->
<Border
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,0,1,0"
DockPanel.Dock="Left">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
VirtualizingStackPanel.IsVirtualizing="False">
<ListBox.ItemTemplate>
<DataTemplate>
<materialDesign:TransitioningContent>
<materialDesign:TransitioningContent.OpeningEffect>
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
</materialDesign:TransitioningContent.OpeningEffect>
<Grid
Margin="-8"
Background="Transparent"
Cursor="Hand"
ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource DividerBrush}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Grid>
</materialDesign:TransitioningContent>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border>
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding AvailableChannels}"
VirtualizingStackPanel.IsVirtualizing="False">
<ListBox.ItemTemplate>
<DataTemplate>
<materialDesign:TransitioningContent>
<materialDesign:TransitioningContent.OpeningEffect>
<materialDesign:TransitionEffect Kind="SlideInFromLeft" Duration="0:0:0.3" />
</materialDesign:TransitioningContent.OpeningEffect>
<StackPanel
Margin="-8"
Background="Transparent"
Cursor="Hand"
Orientation="Horizontal">
<StackPanel.InputBindings>
<MouseBinding
Command="{Binding DataContext.ShowExportSetupCommand, RelativeSource={RelativeSource FindAncestor, AncestorType={x:Type ItemsControl}}}"
CommandParameter="{Binding}"
MouseAction="LeftClick" />
</StackPanel.InputBindings>
<materialDesign:PackIcon
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
<TextBlock
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14"
Text="{Binding Name}" />
</StackPanel>
</materialDesign:TransitioningContent>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</DockPanel>
<!-- Usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding IsDataAvailable, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<!-- User token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource InvertBoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your user token to work." />
<TextBlock
Margin="0,8,0,0"
FontSize="16"
Text="To obtain it, follow these steps:" />
<TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="1. Open the Discord app" />
<LineBreak />
<Run Text="2. Log in if you haven't" />
<LineBreak />
<Run Text="3. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="4. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
<Run Text="to trigger reload" />
<LineBreak />
<Run Text="5. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="6. Select" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
<Run Text="&gt;" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" />
<Run Text="on the left" />
<LineBreak />
<Run Text="7. Find" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
<Run Text="under key and copy the value" />
</TextBlock>
</StackPanel>
<!-- Bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock FontSize="18" Text="DiscordChatExporter needs your bot token to work." />
<TextBlock
Margin="0,8,0,0"
FontSize="16"
Text="To obtain it, follow these steps:" />
<TextBlock Margin="8,0,0,0" FontSize="14">
<Run Text="1. Go to Discord developer portal" />
<LineBreak />
<Run Text="2. Log in if you haven't" />
<LineBreak />
<Run Text="3. Open your application's settings" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="5. Under" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
<Run Text="click" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
</TextBlock>
</StackPanel>
</Grid>
<materialDesign:Snackbar x:Name="Snackbar" />
</Grid>
</DockPanel>
</materialDesign:DialogHost>
</Window>
@@ -0,0 +1,35 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Messages;
using GalaSoft.MvvmLight.Messaging;
using MaterialDesignThemes.Wpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.Views
{
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Snackbar.MessageQueue = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
// Notification messages
Messenger.Default.Register<ShowNotificationMessage>(this, m =>
{
if (m.CallbackCaption != null && m.Callback != null)
Snackbar.MessageQueue.Enqueue(m.Message, m.CallbackCaption, m.Callback);
else
Snackbar.MessageQueue.Enqueue(m.Message);
});
// Dialog messages
Messenger.Default.Register<ShowExportSetupMessage>(this,
m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this,
m => DialogHost.Show(new SettingsDialog()).Forget());
}
}
}
@@ -0,0 +1,45 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.SettingsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
Width="250"
DataContext="{Binding SettingsViewModel, Source={StaticResource Container}}">
<StackPanel>
<!-- Date format -->
<TextBox
Margin="16,16,16,8"
materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}" />
<!-- Message group limit -->
<TextBox
Margin="16,8,16,8"
materialDesign:HintAssist.Hint="Message group limit"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding MessageGroupLimit}" />
<!-- Auto-updates -->
<DockPanel LastChildFill="False">
<TextBlock
Margin="16,8,16,8"
DockPanel.Dock="Left"
Text="Auto-updates" />
<ToggleButton
Margin="16,8,16,8"
DockPanel.Dock="Right"
IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
<!-- Save button -->
<Button
Margin="8"
HorizontalAlignment="Right"
Command="{x:Static materialDesign:DialogHost.CloseDialogCommand}"
Content="SAVE"
IsCancel="True"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
</StackPanel>
</UserControl>
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views
{
public partial class SettingsDialog
{
public SettingsDialog()
{
InitializeComponent();
}
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="CommonServiceLocator" publicKeyToken="489b6accfaf20ef0" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-2.0.3.0" newVersion="2.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>
+23 -7
View File
@@ -1,28 +1,44 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15 # Visual Studio 15
VisualStudioVersion = 15.0.26430.13 VisualStudioVersion = 15.0.27130.2026
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter", "DiscordChatExporter\DiscordChatExporter.csproj", "{4BE915D1-129C-49E2-860E-62045ACA5EAD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
Changelog.md = Changelog.md
License.txt = License.txt License.txt = License.txt
Readme.md = Readme.md Readme.md = Readme.md
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core", "DiscordChatExporter.Core\DiscordChatExporter.Core.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Debug|Any CPU.Build.0 = Debug|Any CPU {732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.ActiveCfg = Release|Any CPU {732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4BE915D1-129C-49E2-860E-62045ACA5EAD}.Release|Any CPU.Build.0 = Release|Any CPU {732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.Build.0 = Release|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {51587D08-01E1-4511-AC57-A417D1A9162F}
EndGlobalSection
EndGlobal EndGlobal
@@ -1,26 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net45</TargetFramework>
<AssemblyVersion>1.0.0</AssemblyVersion>
<FileVersion>$(AssemblyVersion)</FileVersion>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017 Alexey Golub</Copyright>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Services\ExportTemplate.html" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.5.1" />
<PackageReference Include="Newtonsoft.json" Version="10.0.3" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.4.0" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Net.Http" />
</ItemGroup>
</Project>
-21
View File
@@ -1,21 +0,0 @@
namespace DiscordChatExporter.Models
{
public class Attachment
{
public string Id { get; }
public string Url { get; }
public string FileName { get; }
public bool IsImage { get; }
public Attachment(string id, string url, string fileName, bool isImage)
{
Id = id;
Url = url;
FileName = fileName;
IsImage = isImage;
}
}
}
-22
View File
@@ -1,22 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Models
{
public class ChatLog
{
public string ChannelId { get; }
public IReadOnlyList<User> Participants { get; }
public IReadOnlyList<Message> Messages { get; }
public ChatLog(string channelId, IEnumerable<Message> messages)
{
ChannelId = channelId;
Messages = messages.ToArray();
Participants = Messages.Select(m => m.Author).Distinct(a => a.Name).ToArray();
}
}
}
-36
View File
@@ -1,36 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
public class Message
{
public string Id { get; }
public DateTime TimeStamp { get; }
public DateTime? EditedTimeStamp { get; }
public User Author { get; }
public string Content { get; }
public IReadOnlyList<Attachment> Attachments { get; }
public Message(string id, DateTime timeStamp, DateTime? editedTimeStamp, User author, string content, IEnumerable<Attachment> attachments)
{
Id = id;
TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp;
Author = author;
Content = content;
Attachments = attachments.ToArray();
}
public override string ToString()
{
return Content;
}
}
}
@@ -1,22 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
public class MessageGroup
{
public User Author { get; }
public DateTime FirstTimeStamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime firstTimeStamp, IEnumerable<Message> messages)
{
Author = author;
FirstTimeStamp = firstTimeStamp;
Messages = messages.ToArray();
}
}
}
-15
View File
@@ -1,15 +0,0 @@
namespace DiscordChatExporter.Models
{
public class Options
{
public string Token { get; }
public string ChannelId { get; }
public Options(string token, string channelId)
{
Token = token;
ChannelId = channelId;
}
}
}
-29
View File
@@ -1,29 +0,0 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Models
{
public class User
{
public string Id { get; }
public string Name { get; }
public string AvatarHash { get; }
public string AvatarUrl => AvatarHash.IsNotBlank()
? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png?size=256"
: "https://discordapp.com/assets/6debd47ed13483642cf09e832ed0bc1b.png";
public User(string id, string name, string avatarHash)
{
Id = id;
Name = name;
AvatarHash = avatarHash;
}
public override string ToString()
{
return Name;
}
}
}
-66
View File
@@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter
{
public static class Program
{
private static readonly DiscordApiService DiscordApiService = new DiscordApiService();
private static readonly ExportService ExportService = new ExportService();
private static Options GetOptions(string[] args)
{
// Parse the arguments
var argsDic = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (string arg in args)
{
var match = Regex.Match(arg, "/(.*?):\"?(.*?)\"?$");
string key = match.Groups[1].Value;
string value = match.Groups[2].Value;
if (key.IsBlank())
continue;
argsDic[key] = value;
}
// Extract required arguments
string token = argsDic.GetOrDefault("token");
string channelId = argsDic.GetOrDefault("channelId");
// Verify arguments
if (token.IsBlank() || channelId.IsBlank())
throw new ArgumentException("Some or all required command line arguments are missing");
// Create option set
return new Options(token, channelId);
}
private static async Task MainAsync(string[] args)
{
// Parse cmd args
var options = GetOptions(args);
// Get messages
Console.WriteLine("Getting messages...");
var messages = await DiscordApiService.GetMessagesAsync(options.Token, options.ChannelId);
var chatLog = new ChatLog(options.ChannelId, messages);
// Export
Console.WriteLine("Exporting messages...");
ExportService.Export($"{options.ChannelId}.html", chatLog);
}
public static void Main(string[] args)
{
Console.Title = "Discord Chat Exporter";
MainAsync(args).GetAwaiter().GetResult();
}
}
}
@@ -1,99 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
public class DiscordApiService
{
private const string ApiRoot = "https://discordapp.com/api";
private readonly HttpClient _httpClient = new HttpClient();
private IEnumerable<Message> ParseMessages(string json)
{
var messagesJson = JArray.Parse(json);
foreach (var messageJson in messagesJson)
{
// Get basic data
string id = messageJson.Value<string>("id");
var timeStamp = messageJson.Value<DateTime>("timestamp");
var editedTimeStamp = messageJson.Value<DateTime?>("edited_timestamp");
string content = messageJson.Value<string>("content");
// Lazy workaround for calls
if (messageJson["call"] != null)
content = "Started a call.";
// Get author
var authorJson = messageJson["author"];
string authorId = authorJson.Value<string>("id");
string authorName = authorJson.Value<string>("username");
string authorAvatarHash = authorJson.Value<string>("avatar");
// Get attachment
var attachmentsJson = messageJson["attachments"];
var attachments = new List<Attachment>();
foreach (var attachmentJson in attachmentsJson)
{
string attachmentId = attachmentJson.Value<string>("id");
string attachmentUrl = attachmentJson.Value<string>("url");
string attachmentFileName = attachmentJson.Value<string>("filename");
bool attachmentIsImage = attachmentJson["width"] != null;
var attachment = new Attachment(attachmentId, attachmentUrl, attachmentFileName, attachmentIsImage);
attachments.Add(attachment);
}
var author = new User(authorId, authorName, authorAvatarHash);
var message = new Message(id, timeStamp, editedTimeStamp, author, content, attachments);
yield return message;
}
}
public async Task<IEnumerable<Message>> GetMessagesAsync(string token, string channelId)
{
var result = new List<Message>();
// We are going backwards from last message to first
// ...collecting everything between them in batches
string beforeId = null;
while (true)
{
// Form request url
string url = $"{ApiRoot}/channels/{channelId}/messages?token={token}&limit=100";
if (beforeId.IsNotBlank())
url += $"&before={beforeId}";
// Get response
string response = await _httpClient.GetStringAsync(url);
// Parse
var messages = ParseMessages(response);
// Add messages to list
string currentMessageId = null;
foreach (var message in messages)
{
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;
}
}
}
@@ -1,185 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using DiscordChatExporter.Models;
using HtmlAgilityPack;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
{
public class ExportService
{
private HtmlDocument GetTemplate()
{
const string templateName = "DiscordChatExporter.Services.ExportTemplate.html";
var assembly = Assembly.GetExecutingAssembly();
using (var stream = assembly.GetManifestResourceStream(templateName))
{
var doc = new HtmlDocument();
doc.Load(stream);
return doc;
}
}
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
{
var result = new List<MessageGroup>();
// Group adjacent messages by timestamp and author
var groupBuffer = new List<Message>();
foreach (var message in messages)
{
var groupFirst = groupBuffer.FirstOrDefault();
// Group break condition
bool breakCondition =
groupFirst != null &&
(
message.Author.Id != groupFirst.Author.Id ||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour
);
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
result.Add(group);
groupBuffer.Clear();
}
// Add message to buffer
groupBuffer.Add(message);
}
// Add what's remaining in buffer
if (groupBuffer.Any())
{
var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
result.Add(group);
}
return result;
}
private string FormatMessageContent(string content)
{
// Encode HTML
content = HtmlDocument.HtmlEncode(content);
// Links from URLs
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
"<a href=\"$1\">$1</a>");
// Preformatted multiline
content = Regex.Replace(content, "```([^`]*?)```",
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
// Preformatted inline
content = Regex.Replace(content, "`([^`]*?)`",
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
// Bold
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
// Italic
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
// Underline
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
// Strike through
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
// New lines
content = content.Replace("\n", "</br>");
return content;
}
public void Export(string filePath, ChatLog chatLog)
{
var doc = GetTemplate();
// Info
var infoHtml = doc.GetElementbyId("info");
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Channel ID: <b>{chatLog.ChannelId}</b></div>"));
string participants = HtmlDocument.HtmlEncode(chatLog.Participants.Select(u => u.Name).JoinToString(", "));
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Participants: <b>{participants}</b></div>"));
infoHtml.AppendChild(HtmlNode.CreateNode($"<div>Messages: <b>{chatLog.Messages.Count:N0}</b></div>"));
// Log
var logHtml = doc.GetElementbyId("log");
var messageGroups = GroupMessages(chatLog.Messages);
foreach (var messageGroup in messageGroups)
{
// Container
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
// Avatar
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-avatar\">" +
$"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />" +
"</div>"));
// Body
var messageBodyHtml = messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-body\"></div>"));
// Author
string authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
// Date
string timeStamp = HtmlDocument.HtmlEncode(messageGroup.FirstTimeStamp.ToString("g"));
messageBodyHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
// Individual messages
foreach (var message in messageGroup.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
string content = FormatMessageContent(message.Content);
var contentHtml =
messageBodyHtml.AppendChild(
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
// Edited timestamp
if (message.EditedTimeStamp != null)
{
contentHtml.AppendChild(
HtmlNode.CreateNode(
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp:g}\">(edited)</span>"));
}
}
// Attachments
foreach (var attachment in message.Attachments)
{
if (attachment.IsImage)
{
messageBodyHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
"</a>" +
"</div>"));
}
else
{
messageBodyHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"Attachment: {attachment.FileName}" +
"</a>" +
"</div>"));
}
}
}
}
doc.Save(filePath);
}
}
}
@@ -1,122 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Discord Chat Log</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: Whitney, Helvetica Neue, Helvetica, Arial, sans-serif;
font-size: 15px;
background-color: #fff;
}
a {
text-decoration: none;
color: #37bcf7;
}
a:hover {
text-decoration: underline;
}
div.pre, span.pre {
font-family: Consolas, Courier New, Courier, Monospace;
padding-right: 2px;
padding-left: 2px;
background-color: #f9f9f9;
}
div#info {
max-width: 100%;
margin-bottom: 20px;
color: #939799;
}
div#log {
max-width: 100%;
}
div.msg {
display: flex;
margin-right: 10px;
margin-left: 10px;
padding-top: 15px;
padding-bottom: 15px;
border-top: 1px solid #eceeef;
}
div.msg-avatar {
width: 40px;
height: 40px;
}
img.msg-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
}
div.msg-body {
margin-left: 15px;
flex: 1;
}
span.msg-user {
font-size: 1.15em;
color: #2f3136;
}
span.msg-date {
font-size: .8em;
font-weight: 200;
margin-left: 5px;
color: #b7bcbf;
}
span.msg-edited {
font-size: .8em;
font-weight: 200;
margin-left: 5px;
color: #b7bcbf;
}
div.msg-content {
padding-top: 5px;
color: #939799;
}
div.msg-attachment {
margin-top: 5px;
margin-bottom: 5px;
}
img.msg-attachment {
max-width: 50%;
max-height: 500px;
}
</style>
</head>
<body>
<div id="info"></div>
<div id="log"></div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
FROM mono:5
WORKDIR /root/build
COPY DiscordChatExporter.sln favicon.ico ./
COPY DiscordChatExporter.Core DiscordChatExporter.Core
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /t:Restore
RUN msbuild ./DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj /p:Configuration=Release
FROM mono:5
COPY --from=0 /root/build/DiscordChatExporter.Cli/bin/Release/net461 /root/bin
WORKDIR /a
ENTRYPOINT ["mono", "/root/bin/DiscordChatExporter.Cli.exe"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2017 Alexey Golub Copyright (c) 2017-2018 Alexey Golub
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+27 -37
View File
@@ -1,49 +1,39 @@
# DiscordChatExporter # DiscordChatExporter
Command line executable that can export [Discord](https://discordapp.com) channel chat logs to a pretty HTML file. [![Build](https://img.shields.io/appveyor/ci/Tyrrrz/DiscordChatExporter/master.svg)](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, embeds, attachments, mentions, reactions and other features. It works with both user and bot tokens, supports multiple output formats and allows you to trim messages by dates.
## Screenshots ## Screenshots
![](http://www.tyrrrz.me/projects/images/discordchatexporter_1.png) ![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/1.png)
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/4.png)
## Download
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
- [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter` (only CLI version)
## Features ## Features
- Produces output styled similar to the Discord's light theme - Graphical and command line interfaces
- Displays user avatars - Supports both user tokens and bot tokens
- Groups messages by author and time - Allows retrieving messages in specified date range
- Handles Discord markdown characters - Multiple export formats: HTML (dark/light), TXT and CSV
- Converts URLs to links - Renders all message features including: markdown, attachments, embeds, emojis, mentions, etc
- Inlines attached images with a link to the full version
- Inserts download links for other types of attached files
- Marks edited messages with a timestamp
## Usage
The program expects an access token and channel ID as parameters.
`DiscordChatExporter.exe /token:REkOTVqm9RWOTNOLCdiuMpWd.QiglBz.Lub0E0TZ1xX4ZxCtnwtpBhWt3v1 /channelId:459360869055190534`
#### Getting access token:
- Open Discord desktop or web client
- Press `Ctrl+Shift+I`
- Navigate to `Storage > LocalStorage > discordapp.com`
- Find the value for `token` and extract it
#### Getting channel ID:
- Open Discord desktop or web client
- Navigate to any DM or server channel
- Extract the current URL:
- If using desktop client, press `Ctrl+Shift+I`, type `window.location.href` in console and extract the result
- If using web client, just take the current URL from the address bar
- Pull the ID from the URL:
- If it's a DM channel, the format looks like this: `https://discordapp.com/channels/@me/CHANNEL_ID`
- If it's a server channel, the format looks like this:
`https://discordapp.com/channels/WHATEVER/CHANNEL_ID`
## Libraries used ## Libraries used
- [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack) - [GalaSoft.MVVMLight](http://www.mvvmlight.net)
- [Newtonsoft.Json](https://github.com/JamesNK/Newtonsoft.Json) - [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [Scriban](https://github.com/lunet-io/scriban)
- [Polly](https://github.com/App-vNext/Polly)
- [Onova](https://github.com/Tyrrrz/Onova)
- [CommandLineParser](https://github.com/commandlineparser/commandline)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions) - [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.WpfExtensions](https://github.com/Tyrrrz/WpfExtensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)
+29
View File
@@ -0,0 +1,29 @@
version: '{build}'
image: Visual Studio 2017
configuration: Release
before_build:
- ps: nuget restore
build:
verbosity: minimal
after_build:
- ps: Deploy\Prepare.ps1
artifacts:
- path: Deploy\bin\DiscordChatExporter.zip
name: DiscordChatExporter.zip
- path: Deploy\bin\DiscordChatExporter.Cli.zip
name: DiscordChatExporter.Cli.zip
deploy:
- provider: GitHub
auth_token:
secure: sjQHWRw29AMiVMn3MtidtWnAzAf1mJ+mkJ/7h1B9TIAHhkFrqwMK7LtXV+uNJ9AO
artifact: DiscordChatExporter.zip,DiscordChatExporter.Cli.zip
description: '[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)'
on:
branch: master
appveyor_repo_tag: true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

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