Compare commits

...

131 Commits

Author SHA1 Message Date
Alexey Golub 76c7f9b419 Update version 2019-06-06 23:38:06 +03:00
Alexey Golub 9a383d2bd4 Convert from DateTime to DateTimeOffset instead of parsing it directly
Json.NET is really not meant to be used with DateTimeOffset it seems

Fixes #179
2019-06-06 23:31:40 +03:00
Oleksii Holub 2ed374ee5c [HTML] Render user ID inside data-user-id attribute
Closes #93
2019-06-06 17:43:30 +03:00
Alexey Golub 0ced31ca4a Update version 2019-05-15 01:04:21 +03:00
Alexey Golub 3bb5638b56 Improve usage instructions slightly 2019-05-15 00:56:50 +03:00
Malcolm Diller 3b5d752c95 Add bot tags and some CSS fixes (#172) 2019-05-09 13:52:46 +03:00
Alexey Golub 2b1f599cbe Add logo image 2019-04-25 22:14:17 +03:00
Alexey Golub 7cb1c21897 Refactor 2019-04-18 22:21:20 +03:00
Alexey Golub 37bcea86e3 [CLI] Update usage guide to explain steps needed to extract DM channel ID 2019-04-18 22:19:26 +03:00
Alexey Golub 559ef71d2f Get rid of Chocolatey 2019-04-17 00:59:22 +03:00
Alexey Golub ab0ce478db Move UpdateService to GUI project 2019-04-16 23:08:38 +03:00
Alexey Golub afb9f52221 Update FodyWeavers.xsd 2019-04-16 23:08:22 +03:00
Alexey Golub d65ca409b1 Update nuget packages 2019-04-13 02:42:03 +03:00
Oleksii Holub 7784383ea7 Fix grammar mistakes in comments 2019-04-12 17:13:12 +03:00
Oleksii Holub 26322da800 [GUI] Notify when all exports are done instead of each of them separately 2019-04-12 16:24:17 +03:00
Oleksii Holub 1325edb135 Use latest C# version in all projects 2019-04-12 16:10:28 +03:00
Oleksii Holub dec2c26d83 Update appveyor image to vs2019 2019-04-12 16:01:01 +03:00
Oleksii Holub 82b0b8cb0a [GUI] Block UI when an export is underway 2019-04-12 15:23:07 +03:00
Oleksii Holub a7da90943f Remove trailing whitespace 2019-04-12 13:48:13 +03:00
Alexey Golub 1edeaf9d3a Update changelog 2019-04-12 01:03:42 +03:00
Alexey Golub 9c043376f9 Update version 2019-04-12 00:14:54 +03:00
Alexey Golub 77366cc9b4 Escape specific emojis in markdown
Fixes a part of #146
2019-04-12 00:11:01 +03:00
Alexey Golub ffdca7b799 [HTML] Fix multiple emojis not getting jumbo 2019-04-11 23:31:29 +03:00
Alexey Golub 30cba7959f Refactor string checks and fix exception when exporting multiple channels
Fix #164
2019-04-11 22:56:29 +03:00
Alexey Golub 7951703cf2 Update version 2019-04-11 01:52:50 +03:00
Alexey Golub 795dfbb213 [CLI] Don't show 'completed' when export failed 2019-04-11 01:36:39 +03:00
Alexey Golub 6d9bc3625f Switch from DateTime to DateTimeOffset 2019-04-11 01:20:52 +03:00
Alexey Golub 4bfb2ec7fd Improve performance (#162) 2019-04-10 23:45:21 +03:00
Oleksii Holub 359278afec Optimize chocolatey package 2019-04-09 18:15:17 +03:00
Oleksii Holub 19f58f2c8c Update readme 2019-04-09 16:32:24 +03:00
Alexey Golub 640b1f5866 Add deploy via chocolatey 2019-04-07 13:01:38 +03:00
Oleksii Holub d13d04e75b Add donation info 2019-04-03 18:33:34 +03:00
Oleksii Holub 6bf0ce3790 Fix dockerfile again 2019-03-25 15:22:51 +02:00
Oleksii Holub aef8a1f6d2 Fix default output directory in dockerfile 2019-03-25 14:35:24 +02:00
Oleksii Holub a63bf95212 Update dockerfile 2019-03-25 11:04:51 +02:00
Oleksii Holub af9855fc15 [CLI] Copy nuget dependencies to output 2019-03-25 11:00:13 +02:00
Alexey Golub 4113101c0c Get rid of ByteSize in favor of our own class 2019-03-24 12:36:25 +02:00
Oleksii Holub cac48b3541 Optimize dockerfile 2019-03-22 15:09:07 +02:00
Alexey Golub 0a60dea5c1 Use net46, netstd2.0, and netcoreapp2.1 for target frameworks
Closes #154
2019-03-22 00:07:13 +02:00
Alexey Golub 889446fff7 Update nuget packages 2019-03-21 22:54:12 +02:00
Alexey Golub 6d1cfef729 Update Onova 2019-03-21 00:55:24 +02:00
Oleksii Holub 77bf32aa5a Update version 2019-03-14 16:36:55 +02:00
Oleksii Holub ac2b450892 Retry on any 5xx HTTP response in DataService
Closes #157
2019-03-14 16:30:27 +02:00
Oleksii Holub 4046fd0ad9 [HTML] Add syntax highlighting via Highlight.js
Closes #138
2019-03-14 16:02:39 +02:00
Alexey Golub 6fa61bf75a Update version 2019-03-10 14:45:39 +02:00
Alexey Golub 1976e3ad08 [CLI] Don't write progress if output is redirected
FIxes #155
2019-03-10 14:40:08 +02:00
Alexey Golub adbe544c57 Add some margin at the end of the chatlog in HTML 2019-03-07 22:15:21 +02:00
Alexey Golub 6f4d1c3d77 Refactor message grouping 2019-03-07 22:13:00 +02:00
Alexey Golub 33f71c1612 Remove message group limit setting 2019-03-07 21:14:11 +02:00
Alexey Golub 9b78472e05 Update message grouping algorithm to match Discord's
Fixes #152
2019-03-07 21:10:22 +02:00
Alexey Golub 1f6d1f21e6 Update version 2019-03-06 23:00:56 +02:00
Alexey Golub 918cb4820f Add alt text to emoji images
Closes #151
2019-03-06 22:57:18 +02:00
Alexey Golub 32af1aa05d Fix incorrect regex for capturing standard emojis
Fixes #147
2019-03-06 22:48:24 +02:00
Alexey Golub dda32d8af2 Fix markdown grammar being too greedy in some places
Fixes #150
2019-03-06 22:37:36 +02:00
Alexey Golub d1d82f975b Add license file to releasable artifacts 2019-03-03 22:26:02 +02:00
Alexey Golub b1de9c4eca Update version 2019-03-03 21:48:03 +02:00
Alexey Golub 48ec4d085a Fix bad overflow wrapping 2019-03-03 21:33:27 +02:00
Alexey Golub 668ac70fae Fix some parsing issues 2019-03-03 21:24:37 +02:00
Alexey Golub 0d1ce38489 Jumboable emojis 2019-03-03 21:09:47 +02:00
Alexey Golub 666f2d8d4a Fix emoji and reaction css 2019-03-03 20:47:24 +02:00
Alexey Golub 2dd0742c1c Fix video attachments getting rendered as images
Fixes #120
2019-03-03 20:20:01 +02:00
Alexey Golub 69fe8af87a Update Failsafe 2019-03-03 20:08:47 +02:00
Alexey Golub e493357a9f Add progress reporting to CLI
Closes #143
2019-03-03 19:59:23 +02:00
Alexey Golub 492003c75a Fix incorrect standard emoji rendering in plaintext 2019-03-03 18:41:54 +02:00
Alexey Golub 28175e2110 Parse emojis in markdown and render them using Twemoji
Closes #140
2019-03-03 18:36:12 +02:00
Alexey Golub a564906719 Use ByteSize for file size in attachments 2019-03-03 16:38:41 +02:00
Alexey Golub 019278f6d4 Fix dockerfile 2019-03-03 16:05:09 +02:00
Alexey Golub 5161593e0f Forgot to commit DataService.Parsers 2019-03-03 15:40:26 +02:00
Alexey Golub 847a05a269 Refactor reaction emojis into a separate class, add support for animations and standard emojis via twemoji
Closes #71
2019-03-03 15:24:31 +02:00
Alexey Golub f09f30c7bd Implement a more sophisticated markdown parsing engine (#145) 2019-03-03 14:16:12 +02:00
Alexey Golub 88727a1fe6 Forgot one more place 2019-02-17 19:55:48 +02:00
Alexey Golub bb98049df7 Remove years from copyright 2019-02-17 19:48:54 +02:00
Alexey Golub e519bdcc6f Update license 2019-02-17 19:31:26 +02:00
Oleksii Holub 1356d4483a Add a checkmark that displays whether a channel is selected 2019-02-15 13:01:30 +02:00
Alexey Golub 4a3dc5c5ee Bind export button visibility on the guard property 2019-02-14 22:29:52 +02:00
Alexey Golub e665356890 Show export progress in taskbar 2019-02-14 21:52:36 +02:00
Alexey Golub 7b614ed448 Update version 2019-02-10 17:27:45 +02:00
Alexey Golub 6531462220 Fix getting last message when there's no date range
Fixes #137
2019-02-10 17:26:18 +02:00
Alexey Golub e89f2735a0 Update version 2019-02-09 21:26:15 +02:00
Alexey Golub 01d276a9dc Use UTF8 encoding in UpdateVersion.ps1 2019-02-09 21:20:20 +02:00
Alexey Golub c446d4069a Export single channel by double-clicking in GUI 2019-02-09 21:05:31 +02:00
Alexey Golub ba5718aaf5 Improve GUI a bit 2019-02-09 20:07:30 +02:00
Alexey Golub 20ff4a15eb Refactor output path usage in CLI 2019-02-09 19:41:08 +02:00
Alexey Golub 991dfccf3c Retry on 503 status code 2019-02-09 19:39:21 +02:00
Alexey Golub 524c86b8f2 Add channel ID to default export file name to avoid collisions 2019-02-09 19:20:13 +02:00
Alexey Golub e4b0d60c40 Add multichannel export to GUI
Closes #12
2019-02-09 19:03:34 +02:00
Alexey Golub 65c5df89f4 Use Failsafe 2019-02-08 20:37:53 +02:00
Oleksii Holub 659c499bd0 Small clenaup 2019-02-01 15:33:06 +02:00
Alexey Golub b6fcbac030 Refactor 2019-01-30 23:14:17 +02:00
Oleksii Holub b3e2dd3994 Group channels by category in GUI
Closes #77
2019-01-30 18:16:46 +02:00
Alexey Golub ba5790e312 Remove reference to Tyrrrz.Settings in GUI project 2019-01-21 23:21:35 +02:00
Alexey Golub 2859a740eb Update nuget packages 2019-01-07 18:29:36 +02:00
Oleksii Holub b88a1f8b47 Some refactor 2018-12-21 15:37:14 +02:00
Alexey Golub 78b44aed19 Use embedded style for dialog host 2018-12-19 21:51:03 +02:00
Oleksii Holub 13925c69af Refactor CLI and add 'exportdm' command to export all direct message channels 2018-12-19 17:29:36 +02:00
Oleksii Holub e22e1c03fb Cleanup 2018-12-19 17:15:54 +02:00
TVdata bf5cedb646 Add CLI command to export all available channels in a guild (#123) 2018-12-19 16:53:21 +02:00
Alexey Golub 17b0161ade Make dialogs white again 2018-12-16 00:07:17 +02:00
Alexey Golub fa1e84b518 Refactor UI 2018-12-15 23:53:52 +02:00
Alexey Golub 6d5ed9e9ca Improve UI 2018-12-14 01:00:57 +02:00
Oleksii Holub bdaeea5512 Refactor busy state and progress reporting 2018-12-11 18:03:32 +02:00
Alexey Golub dddb13fcc5 Refactor default file name generation and add date ranges there
Closes #29
2018-12-10 23:16:15 +02:00
Alexey Golub cacbb58683 Improve usage instructions a bit 2018-12-08 23:56:04 +02:00
Alexey Golub ca727e9f23 Improve naming of exported partition files 2018-12-08 23:55:49 +02:00
Oleksii Holub fd15b62adf Improve UI layout and usage instructions 2018-12-07 18:14:27 +02:00
Oleksii Holub 78d01b47b4 Add some tooltips 2018-12-07 17:05:17 +02:00
Oleksii Holub daf38153f4 Improve UI some more 2018-12-05 18:45:13 +02:00
Oleksii Holub ac91c536dd Improve ExportSetupView UI 2018-12-05 18:20:20 +02:00
Alexey Golub 0dbd619244 Remove unsused library from readme 2018-11-29 20:55:20 +02:00
Alexey Golub 0d3510222e Migrate to Stylet and refactor view/view-model framework 2018-11-29 19:18:44 +02:00
Alexey Golub 083bdef419 Move screenshots section below features section in readme 2018-11-17 23:22:25 +02:00
Oleksii Holub d620010e85 Add some safeguarding in case partition limit is less or equal to zero 2018-11-15 10:26:54 +02:00
Oleksii Holub beadc25e61 Cleanup GroupMessages 2018-11-14 18:15:07 +02:00
Oleksii Holub 8bc29c4928 Refactor SplitIntoPartitions 2018-11-14 17:51:03 +02:00
Oleksii Holub 0b14952346 Add a script to update version in all projects 2018-11-05 13:31:42 +02:00
Alexey Golub b518fb2f59 Update version 2018-11-03 23:51:19 +02:00
Alexey Golub 94165dcd8d Don't render the template in memory to avoid out of memory exceptions
Fixes #88
2018-11-03 23:04:26 +02:00
Alexey Golub d6507598fa Fix not being able to reset partition limit in GUI 2018-11-03 23:01:58 +02:00
Alexey Golub a0359b1e43 Split output file into multiple partitions (#116) 2018-11-03 22:53:20 +02:00
Oleksii Holub aa53cecd4e Don't open exported file on completion
Fixes #85
2018-11-02 17:12:17 +02:00
Oleksii Holub 4144911208 Remove autoupdate feature from CLI entirely 2018-11-02 16:24:06 +02:00
Oleksii Holub d28e81f8dc Don't replace new lines in csv
Part of #102
2018-11-01 18:33:29 +02:00
Oleksii Holub 761cb032d7 Don't group messages in PlainText export
Part of #102
2018-11-01 18:27:41 +02:00
Oleksii Holub 47f0561c71 Refactor message grouping from data layer to render layer 2018-11-01 18:16:23 +02:00
Oleksii Holub 23116b776b Show unhandled exceptions to users 2018-11-01 14:43:21 +02:00
Oleksii Holub ba27a9f909 Rename ExportService.Export to ExportChatLog for consistency 2018-10-31 17:54:13 +02:00
Oleksii Holub 52684c264f Change order of parameters in ExportService.Export 2018-10-31 17:35:28 +02:00
Oleksii Holub bcad5b4ed1 Set ChatLog.TotalMessageCount from ChatLogService 2018-10-31 17:34:57 +02:00
Alexey Golub 95a4217ab3 Refactor resolving chat log to a separate service to allow reusability across CLI and GUI 2018-10-30 22:52:28 +02:00
Alexey Golub 4e8fb80ac5 Use PackageReference 2018-10-28 15:25:22 +02:00
Alexey Golub 7ae560dde0 Fix changelog 2018-09-14 00:36:31 +03:00
153 changed files with 4636 additions and 2427 deletions
+83 -2
View File
@@ -1,9 +1,90 @@
### v2.13.1 (06-Jun-2019)
- Fixed an issue where the app sometimes crashed when exporting due to `System.InvalidCastException`.
- [HTML] Added `data-user-id` attribute to `span.chatlog__author-name`. The value of this attribute is author's Discord user ID.
### v2.13 (15-May-2019)
- Updated usage instructions.
- [HTML] Added BOT tags to messages posted by bots.
- [GUI] UI will now be disabled when exporting.
- [GUI] Notification informing that the export has completed will now appear after all exports have completed, instead of each of them separately.
### v2.12.1 (12-Apr-2019)
- [GUI] Fixed an issue where the app crashed when trying to export multiple channels.
- [HTML] Fixed an issue where some of the emojis were rendered via Twemoji while Discord renders them as plain text.
- [HTML] Fixed an issue where emojis weren't jumbo if there were more than one of them.
### v2.12 (11-Apr-2019)
- Improved markdown parsing performance which speeds up the final stage of exporting by around 4.5 times.
- Improved performance when exporting as PlainText or CSV.
- [GUI] Fixed an issue where the app would freeze up and report as "not responding" during the final stage of exporting.
- [CLI] Fixed an issue where it would say "Completed" after exporting a chat log, even if the process failed.
### v2.11 (14-Mar-2019)
- [HTML] Added syntax highlighting for multiline code blocks via Highlight.js.
- Added retry policy for all 5xx status codes to prevent random crashes.
### v2.10.2 (10-Mar-2019)
- [HTML] Updated message grouping algorithm to make it the same as in Discord. Removed "message group limit" setting and parameter.
- [HTML] Added small margin at the end of the chatlog so it doesn't look like it was truncated.
- [CLI] Fixed an issue where the app would crash if stdout was redirected. Progress will not be reported in such cases.
### v2.10.1 (06-Mar-2019)
- [HTML] Fixed an issue where multiple emojis on a single line would get rendered as one emoji.
- [HTML] Fixed an issue where an open square bracket character would incorrectly render as an emoji.
- [HTML] Added alt text to emoji images so that they will be rendered using default font when Twemoji images are not available.
### v2.10 (03-Mar-2019)
- [HTML] Reworked the entire markdown parsing engine which fixes numerous rendering inconsistencies (over 15 issues fixed).
- [HTML] Added support for animated emojis.
- [HTML] Standard emojis are now rendered using Twemoji, the same image set that Discord uses.
- [HTML] Fixed an issue where video attachments would render incorrectly.
- [HTML] Minor fixes in styling.
- [GUI] Aggregated export progress is now shown in the taskbar.
- [GUI] Checkmarks are now displayed next to channels that are selected.
- [CLI] Added progress reporting when exporting chat logs.
### v2.9.1 (10-Feb-2019)
- Fixed an issue where some of the last messages in a channel would sometimes not get exported.
### v2.9 (09-Feb-2019)
- Added categories to channel list in GUI. Channels are now also grouped by category.
- Added ability to export multiple channels at once in GUI by selecting them with shift or ctrl keys.
- Added CLI command to export all available channels in a guild (`exportguild`).
- Added CLI command to export all direct message channels (`exportdm`).
- Added retry policy for 503 status code errors to prevent random crashes.
- Changed how channels are exported in GUI. Now you need to select them first and then click a button to export. You can also double click a single channel to export it as before.
- Improved default file name generation by adding date ranges and channel ID to the file name.
- Improved naming of files exported with partitioning.
- Improved usage instructions.
- Improved GUI layout and styling.
- Fixed an issue where dialogs would sometimes appear over other windows.
- Fixed an issue where launching DiscordChatExporter from Windows Start Menu would make it crash.
### v2.8 (03-Nov-2018)
- Added support for partitioning which lets you split the output into multiple files by setting the partition message limit (`-p` parameter for CLI).
- Exported file will no longer open automatically on completion.
- Reduced amount of memory used during exporting.
- Disabled message grouping in PlainText export.
- Improved encoding of newlines in CSV export.
- Improved error messages in the GUI app during crashes.
### v2.7 (14-Sep-2018)
- Updated instructions on how to obtain the user token.
- Expanded CLI with new commands: `channels` (get a list of channels in a guild), `dms` (get a list of DM channels), `guilds` (get a list of guilds), on top of `export` (export chatlog).
- 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, if the provided output file path is a directory, a file name will be generated and appended to it automatically.
- In CLI, the file name will be automatically generated if the provided output file path is a directory.
### v2.6 (25-Jul-2018)
+16 -6
View File
@@ -1,11 +1,21 @@
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
New-Item "$PSScriptRoot\Portable\bin" -ItemType Directory -Force
# GUI
# --- GUI ---
# Get files
$files = @()
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
# CLI
# Pack into archive
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.zip" -Force
# --- CLI ---
# Get files
$files = @()
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net46\*" -Include "*.exe", "*.dll", "*.config"
# Pack into archive
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.CLI.zip" -Force
+15 -18
View File
@@ -1,27 +1,24 @@
using CommonServiceLocator;
using DiscordChatExporter.Core.Services;
using GalaSoft.MvvmLight.Ioc;
using DiscordChatExporter.Core.Services;
using StyletIoC;
namespace DiscordChatExporter.Cli
{
public class Container
public static class Container
{
public Container()
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
SimpleIoc.Default.Reset();
public static IContainer Instance { get; }
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
}
public T Resolve<T>(string key = null)
static Container()
{
return ServiceLocator.Current.GetInstance<T>(key);
var builder = new StyletIoCBuilder();
// Autobind the .Services assembly
builder.Autobind(typeof(DataService).Assembly);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Set instance
Instance = builder.BuildContainer();
}
}
}
@@ -1,23 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net461</TargetFramework>
<Version>2.7</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
<Version>2.13.1</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<LangVersion>latest</LangVersion>
</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>
<PackageReference Include="CommandLineParser" Version="2.3.0" />
<PackageReference Include="Stylet" Version="1.1.22" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
<ProjectReference Include="..\DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,47 @@
using System;
namespace DiscordChatExporter.Cli.Internal
{
internal class InlineProgress : IProgress<double>, IDisposable
{
private readonly int _posX;
private readonly int _posY;
private bool _isCompleted;
public InlineProgress()
{
// If output is not redirected - save initial cursor position
if (!Console.IsOutputRedirected)
{
_posX = Console.CursorLeft;
_posY = Console.CursorTop;
}
}
public void Report(double progress)
{
// If output is not redirected - reset cursor position and write progress
if (!Console.IsOutputRedirected)
{
Console.SetCursorPosition(_posX, _posY);
Console.WriteLine($"{progress:P1}");
}
}
public void ReportCompletion() => _isCompleted = true;
public void Dispose()
{
// If output is not redirected - reset cursor position
if (!Console.IsOutputRedirected)
Console.SetCursorPosition(_posX, _posY);
// Inform about completion
if (_isCompleted)
Console.WriteLine("Completed ✓");
else
Console.WriteLine("Failed X");
}
}
}
+30 -23
View File
@@ -11,28 +11,33 @@ namespace DiscordChatExporter.Cli
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(" 1. Open Discord");
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 3. Navigate to the Application tab");
Console.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 5. Press Ctrl+R to reload");
Console.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
Console.WriteLine();
Console.WriteLine("# 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(" 2. Open your application's settings");
Console.WriteLine(" 3. Navigate to the Bot section on the left");
Console.WriteLine(" 4. Under Token click Copy");
Console.WriteLine();
Console.WriteLine("# To get 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");
Console.WriteLine("# To get guild ID or guild channel ID:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open Settings");
Console.WriteLine(" 3. Go to Appearance section");
Console.WriteLine(" 4. Enable Developer Mode");
Console.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
Console.WriteLine();
Console.WriteLine("# To get direct message channel ID:");
Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open the desired direct message channel");
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 4. Navigate to the Console tab");
Console.WriteLine(" 5. Type \"window.location.href\" and press Enter");
Console.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
}
public static void Main(string[] args)
@@ -40,22 +45,24 @@ namespace DiscordChatExporter.Cli
// Get all verb types
var verbTypes = new[]
{
typeof(ExportChatOptions),
typeof(ExportChannelOptions),
typeof(ExportDirectMessagesOptions),
typeof(ExportGuildOptions),
typeof(GetChannelsOptions),
typeof(GetDirectMessageChannelsOptions),
typeof(GetGuildsOptions),
//typeof(UpdateAppOptions)
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<ExportChannelOptions>(o => new ExportChannelVerb(o).Execute());
parsedArgs.WithParsed<ExportDirectMessagesOptions>(o => new ExportDirectMessagesVerb(o).Execute());
parsedArgs.WithParsed<ExportGuildOptions>(o => new ExportGuildVerb(o).Execute());
parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
parsedArgs.WithParsed<UpdateAppOptions>(o => new UpdateAppVerb(o).Execute());
// Show token help if help requested or no verb specified
parsedArgs.WithNotParsed(errs =>
@@ -0,0 +1,58 @@
using System;
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportChannelVerb : Verb<ExportChannelOptions>
{
public ExportChannelVerb(ExportChannelOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Track progress
Console.Write($"Exporting channel [{Options.ChannelId}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
Options.After, Options.Before, progress);
// Generate file path if not set or is a directory
var filePath = Options.OutputPath;
if (filePath.IsNullOrWhiteSpace() || ExportHelper.IsDirectoryPath(filePath))
{
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Combine paths
filePath = Path.Combine(filePath ?? "", fileName);
}
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
}
}
@@ -1,69 +0,0 @@
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 messageGroupService = container.Resolve<IMessageGroupService>();
var exportService = container.Resolve<IExportService>();
// Configure settings
if (Options.DateFormat.IsNotBlank())
settingsService.DateFormat = Options.DateFormat;
if (Options.MessageGroupLimit > 0)
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
// Get channel and guild
var channel = await dataService.GetChannelAsync(Options.GetToken(), Options.ChannelId);
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await dataService.GetGuildAsync(Options.GetToken(), channel.GuildId);
// Generate file path if not set
var filePath = Options.FilePath;
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
{
filePath += $"{guild.Name} - {channel.Name}.{Options.ExportFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
}
// TODO: extract this to make it reusable across implementations
// Get messages
var messages =
await dataService.GetChannelMessagesAsync(Options.GetToken(), channel.Id,
Options.After, Options.Before);
// Group messages
var messageGroups = messageGroupService.GroupMessages(messages);
// Get mentionables
var mentionables = await dataService.GetMentionablesAsync(Options.GetToken(), guild.Id, messages);
// Create log
var log = new ChatLog(guild, channel, Options.After, Options.Before, messageGroups, mentionables);
// Export
exportService.Export(Options.ExportFormat, filePath, log);
// Print result
Console.WriteLine($"Exported chat to [{filePath}]");
}
}
}
@@ -0,0 +1,78 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportDirectMessagesVerb : Verb<ExportDirectMessagesOptions>
{
public ExportDirectMessagesVerb(ExportDirectMessagesOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -0,0 +1,79 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Internal;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Helpers;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
public class ExportGuildVerb : Verb<ExportGuildOptions>
{
public ExportGuildVerb(ExportGuildOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get services
var settingsService = Container.Instance.Get<SettingsService>();
var dataService = Container.Instance.Get<DataService>();
var exportService = Container.Instance.Get<ExportService>();
// Configure settings
if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat;
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Loop through channels
foreach (var channel in channels)
{
try
{
// Track progress
Console.Write($"Exporting channel [{channel.Name}]... ");
using (var progress = new InlineProgress())
{
// Get chat log
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
Options.After, Options.Before, progress);
// Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
chatLog.Channel, Options.After, Options.Before);
// Generate file path
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
// Export
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
Options.PartitionLimit);
// Report successful completion
progress.ReportCompletion();
}
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
Console.Error.WriteLine("You don't have access to this channel");
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Console.Error.WriteLine("This channel doesn't exist");
}
}
}
}
}
@@ -4,7 +4,6 @@ using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Verbs
{
@@ -18,15 +17,16 @@ namespace DiscordChatExporter.Cli.Verbs
public override async Task ExecuteAsync()
{
// Get data service
var container = new Container();
var dataService = container.Resolve<IDataService>();
var dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
// Filter and order channels
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels.Where(c => c.Type.IsEither(ChannelType.GuildTextChat))
.OrderBy(c => c.Name))
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Cli.Verbs
{
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
{
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
: base(options)
{
}
@@ -16,14 +16,16 @@ namespace DiscordChatExporter.Cli.Verbs
public override async Task ExecuteAsync()
{
// Get data service
var container = new Container();
var dataService = container.Resolve<IDataService>();
var dataService = Container.Instance.Get<DataService>();
// Get channels
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
// Order channels
channels = channels.OrderBy(c => c.Name).ToArray();
// Print result
foreach (var channel in channels.OrderBy(c => c.Name))
foreach (var channel in channels)
Console.WriteLine($"{channel.Id} | {channel.Name}");
}
}
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Cli.Verbs
{
public class GetGuildsVerb : Verb<GetGuildsOptions>
{
public GetGuildsVerb(GetGuildsOptions options)
public GetGuildsVerb(GetGuildsOptions options)
: base(options)
{
}
@@ -16,14 +16,16 @@ namespace DiscordChatExporter.Cli.Verbs
public override async Task ExecuteAsync()
{
// Get data service
var container = new Container();
var dataService = container.Resolve<IDataService>();
var dataService = Container.Instance.Get<DataService>();
// Get guilds
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
// Order guilds
guilds = guilds.OrderBy(g => g.Name).ToArray();
// Print result
foreach (var guild in guilds.OrderBy(g => g.Name))
foreach (var guild in guilds)
Console.WriteLine($"{guild.Id} | {guild.Name}");
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("export", HelpText = "Export channel.")]
public class ExportChannelOptions : ExportOptions
{
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
public string ChannelId { get; set; }
}
}
@@ -0,0 +1,9 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportdm", HelpText = "Export all direct message channels.")]
public class ExportDirectMessagesOptions : ExportOptions
{
}
}
@@ -0,0 +1,11 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("exportguild", HelpText = "Export all channels within a given guild.")]
public class ExportGuildOptions : ExportOptions
{
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
public string GuildId { get; set; }
}
}
@@ -4,28 +4,26 @@ using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("export", HelpText = "Export channel chat log to a file.")]
public class ExportChatOptions : TokenOptions
public abstract class ExportOptions : 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('o', "output", Default = null, HelpText = "Output file or directory path.")]
public string OutputPath { get; set; }
// HACK: CommandLineParser doesn't support DateTimeOffset
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
public DateTime? After { get; set; }
// HACK: CommandLineParser doesn't support DateTimeOffset
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
public DateTime? Before { get; set; }
[Option('p', "partition", Default = null, HelpText = "Split output into partitions limited to this number of messages.")]
public int? PartitionLimit { get; set; }
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
public string DateFormat { get; set; }
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
public int MessageGroupLimit { get; set; }
}
}
@@ -3,7 +3,7 @@ using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Cli.Verbs.Options
{
public class TokenOptions
public abstract class TokenOptions
{
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
public string TokenValue { get; set; }
@@ -1,9 +0,0 @@
using CommandLine;
namespace DiscordChatExporter.Cli.Verbs.Options
{
[Verb("update", HelpText = "Updates this application to the latest version.")]
public class UpdateAppOptions
{
}
}
@@ -1,38 +0,0 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Verbs.Options;
using DiscordChatExporter.Core.Services;
namespace DiscordChatExporter.Cli.Verbs
{
public class UpdateAppVerb : Verb<UpdateAppOptions>
{
public UpdateAppVerb(UpdateAppOptions options)
: base(options)
{
}
public override async Task ExecuteAsync()
{
// Get update service
var container = new Container();
var updateService = container.Resolve<IUpdateService>();
// TODO: this is configured only for GUI
// Get update version
var updateVersion = await updateService.CheckPrepareUpdateAsync();
if (updateVersion != null)
{
Console.WriteLine($"Updating to version {updateVersion}");
updateService.NeedRestart = false;
updateService.FinalizeUpdate();
}
else
{
Console.WriteLine("There are no application updates available.");
}
}
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,46 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class AggregateMatcher<T> : IMatcher<T>
{
private readonly IReadOnlyList<IMatcher<T>> _matchers;
public AggregateMatcher(IReadOnlyList<IMatcher<T>> matchers)
{
_matchers = matchers;
}
public AggregateMatcher(params IMatcher<T>[] matchers)
: this((IReadOnlyList<IMatcher<T>>)matchers)
{
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
{
ParsedMatch<T> earliestMatch = null;
// Try to match the input with each matcher and get the match with the lowest start index
foreach (var matcher in _matchers)
{
// Try to match
var match = matcher.Match(input, startIndex, length);
// If there's no match - continue
if (match == null)
continue;
// If this match is earlier than previous earliest - replace
if (earliestMatch == null || match.StartIndex < earliestMatch.StartIndex)
earliestMatch = match;
// If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that
if (earliestMatch.StartIndex == startIndex)
break;
}
return earliestMatch;
}
}
}
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal static class Extensions
{
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
int startIndex, int length, Func<string, T> fallbackTransform)
{
// Get end index for simplicity
var endIndex = startIndex + length;
// Loop through segments divided by individual matches
var currentIndex = startIndex;
while (currentIndex < endIndex)
{
// Find a match within this segment
var match = matcher.Match(input, currentIndex, endIndex - currentIndex);
// If there's no match - break
if (match == null)
break;
// If this match doesn't start immediately at current index - transform and yield fallback first
if (match.StartIndex > currentIndex)
{
var fallback = input.Substring(currentIndex, match.StartIndex - currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
}
// Yield match
yield return match;
// Shift current index to the end of the match
currentIndex = match.StartIndex + match.Length;
}
// If EOL wasn't reached - transform and yield remaining part as fallback
if (currentIndex < endIndex)
{
var fallback = input.Substring(currentIndex);
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
}
}
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
Func<string, T> fallbackTransform) => matcher.MatchAll(input, 0, input.Length, fallbackTransform);
}
}
@@ -0,0 +1,7 @@
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal interface IMatcher<T>
{
ParsedMatch<T> Match(string input, int startIndex, int length);
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class ParsedMatch<T>
{
public int StartIndex { get; }
public int Length { get; }
public T Value { get; }
public ParsedMatch(int startIndex, int length, T value)
{
StartIndex = startIndex;
Length = length;
Value = value;
}
}
}
@@ -0,0 +1,23 @@
using System;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class RegexMatcher<T> : IMatcher<T>
{
private readonly Regex _regex;
private readonly Func<Match, T> _transform;
public RegexMatcher(Regex regex, Func<Match, T> transform)
{
_regex = regex;
_transform = transform;
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
{
var match = _regex.Match(input, startIndex, length);
return match.Success ? new ParsedMatch<T>(match.Index, match.Length, _transform(match)) : null;
}
}
}
@@ -0,0 +1,29 @@
using System;
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal class StringMatcher<T> : IMatcher<T>
{
private readonly string _needle;
private readonly StringComparison _comparison;
private readonly Func<string, T> _transform;
public StringMatcher(string needle, StringComparison comparison, Func<string, T> transform)
{
_needle = needle;
_comparison = comparison;
_transform = transform;
}
public StringMatcher(string needle, Func<string, T> transform)
: this(needle, StringComparison.Ordinal, transform)
{
}
public ParsedMatch<T> Match(string input, int startIndex, int length)
{
var index = input.IndexOf(_needle, startIndex, length, _comparison);
return index >= 0 ? new ParsedMatch<T>(index, _needle.Length, _transform(_needle)) : null;
}
}
}
@@ -0,0 +1,205 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Markdown.Internal;
using DiscordChatExporter.Core.Markdown.Nodes;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Markdown
{
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
public static class MarkdownParser
{
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant;
/* Formatting */
// Capture any character until the earliest double asterisk not followed by an asterisk
private static readonly IMatcher<Node> BoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "**", TextFormatting.Bold, Parse(m.Groups[1].Value)));
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
// Opening asterisk must not be followed by whitespace
// Closing asterisk must not be preceded by whitespace
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value)));
// Capture any character until the earliest triple asterisk not followed by an asterisk
private static readonly IMatcher<Node> ItalicBoldFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value, BoldFormattedNodeMatcher)));
// Capture any character except underscore until an underscore
// Closing underscore must not be followed by a word character
private static readonly IMatcher<Node> ItalicAltFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value)));
// Capture any character until the earliest double underscore not followed by an underscore
private static readonly IMatcher<Node> UnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "__", TextFormatting.Underline, Parse(m.Groups[1].Value)));
// Capture any character until the earliest triple underscore not followed by an underscore
private static readonly IMatcher<Node> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value, UnderlineFormattedNodeMatcher)));
// Capture any character until the earliest double tilde
private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "~~", TextFormatting.Strikethrough, Parse(m.Groups[1].Value)));
// Capture any character until the earliest double pipe
private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, Parse(m.Groups[1].Value)));
/* Code blocks */
// Capture any character except backtick until a backtick
// Whitespace surrounding content inside backticks is trimmed
private static readonly IMatcher<Node> InlineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline),
m => new InlineCodeBlockNode(m.Value, m.Groups[1].Value.Trim()));
// Capture language identifier and then any character until the earliest triple backtick
// Languge identifier is one word immediately after opening backticks, followed immediately by newline
// Whitespace surrounding content inside backticks is trimmed
private static readonly IMatcher<Node> MultilineCodeBlockNodeMatcher = new RegexMatcher<Node>(
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value.Trim()));
/* Mentions */
// Capture @everyone
private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>(
"@everyone",
s => new MentionNode(s, "everyone", MentionType.Meta));
// Capture @here
private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>(
"@here",
s => new MentionNode(s, "here", MentionType.Meta));
// Capture <@123456> or <@!123456>
private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.User));
// Capture <#123456>
private static readonly IMatcher<Node> ChannelMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<#(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Channel));
// Capture <@&123456>
private static readonly IMatcher<Node> RoleMentionNodeMatcher = new RegexMatcher<Node>(
new Regex("<@&(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Role));
/* Emojis */
// Capture any country flag emoji (two regional indicator surrogate pairs)
// ... or "symbol/other" character
// ... or surrogate pair
// ... or digit followed by enclosing mark
// (this does not match all emojis in Discord but it's reasonably accurate enough)
private static readonly IMatcher<Node> StandardEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|\\p{So}|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
m => new EmojiNode(m.Value, m.Groups[1].Value));
// Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsNullOrWhiteSpace()));
/* Links */
// Capture [title](link)
private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[2].Value, m.Groups[1].Value));
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
private static readonly IMatcher<Node> AutoLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[1].Value));
// Same as auto link but also surrounded by angular brackets
private static readonly IMatcher<Node> HiddenLinkNodeMatcher = new RegexMatcher<Node>(
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
m => new LinkNode(m.Value, m.Groups[1].Value));
/* Text */
// Capture the shrug emoticon
// This escapes it from matching for formatting
private static readonly IMatcher<Node> ShrugTextNodeMatcher = new StringMatcher<Node>(
@"¯\_(ツ)_/¯",
s => new TextNode(s));
// Capture some specific emojis that don't get rendered
// This escapes it from matching for emoji
private static readonly IMatcher<Node> IgnoredEmojiTextNodeMatcher = new RegexMatcher<Node>(
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
// This escapes it from matching for emoji
private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
// This escapes it from matching for formatting or other tokens
private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value));
// Combine all matchers into one
// Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<Node> AggregateNodeMatcher = new AggregateMatcher<Node>(
// Escaped text
ShrugTextNodeMatcher,
IgnoredEmojiTextNodeMatcher,
EscapedSymbolTextNodeMatcher,
EscapedCharacterTextNodeMatcher,
// Formatting
ItalicBoldFormattedNodeMatcher,
ItalicUnderlineFormattedNodeMatcher,
BoldFormattedNodeMatcher,
ItalicFormattedNodeMatcher,
UnderlineFormattedNodeMatcher,
ItalicAltFormattedNodeMatcher,
StrikethroughFormattedNodeMatcher,
SpoilerFormattedNodeMatcher,
// Code blocks
MultilineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher,
// Mentions
EveryoneMentionNodeMatcher,
HereMentionNodeMatcher,
UserMentionNodeMatcher,
ChannelMentionNodeMatcher,
RoleMentionNodeMatcher,
// Links
TitledLinkNodeMatcher,
AutoLinkNodeMatcher,
HiddenLinkNodeMatcher,
// Emoji
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher);
private static IReadOnlyList<Node> Parse(string input, IMatcher<Node> matcher) =>
matcher.MatchAll(input, s => new TextNode(s)).Select(r => r.Value).ToArray();
public static IReadOnlyList<Node> Parse(string input) => Parse(input, AggregateNodeMatcher);
}
}
@@ -0,0 +1,30 @@
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class EmojiNode : Node
{
public string Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public bool IsCustomEmoji => !Id.IsNullOrWhiteSpace();
public EmojiNode(string source, string id, string name, bool isAnimated)
: base(source)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
}
public EmojiNode(string source, string name)
: this(source, null, name, false)
{
}
public override string ToString() => $"<Emoji> {Name}";
}
}
@@ -0,0 +1,23 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class FormattedNode : Node
{
public string Token { get; }
public TextFormatting Formatting { get; }
public IReadOnlyList<Node> Children { get; }
public FormattedNode(string source, string token, TextFormatting formatting, IReadOnlyList<Node> children)
: base(source)
{
Token = token;
Formatting = formatting;
Children = children;
}
public override string ToString() => $"<{Formatting}> ({Children.Count} direct children)";
}
}
@@ -0,0 +1,15 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class InlineCodeBlockNode : Node
{
public string Code { get; }
public InlineCodeBlockNode(string source, string code)
: base(source)
{
Code = code;
}
public override string ToString() => $"<Code> {Code}";
}
}
@@ -0,0 +1,22 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class LinkNode : Node
{
public string Url { get; }
public string Title { get; }
public LinkNode(string source, string url, string title)
: base(source)
{
Url = url;
Title = title;
}
public LinkNode(string source, string url) : this(source, url, url)
{
}
public override string ToString() => $"<Link> {Title}";
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class MentionNode : Node
{
public string Id { get; }
public MentionType Type { get; }
public MentionNode(string source, string id, MentionType type)
: base(source)
{
Id = id;
Type = type;
}
public override string ToString() => $"<{Type} mention> {Id}";
}
}
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public enum MentionType
{
Meta,
User,
Channel,
Role
}
}
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class MultilineCodeBlockNode : Node
{
public string Language { get; }
public string Code { get; }
public MultilineCodeBlockNode(string source, string language, string code)
: base(source)
{
Language = language;
Code = code;
}
public override string ToString() => $"<Code [{Language}]> {Code}";
}
}
@@ -0,0 +1,12 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public abstract class Node
{
public string Source { get; }
protected Node(string source)
{
Source = source;
}
}
}
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public enum TextFormatting
{
Bold,
Italic,
Underline,
Strikethrough,
Spoiler
}
}
@@ -0,0 +1,19 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public class TextNode : Node
{
public string Text { get; }
public TextNode(string source, string text)
: base(source)
{
Text = text;
}
public TextNode(string text) : this(text, text)
{
}
public override string ToString() => Text;
}
}
@@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Linq;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/channel#attachment-object
public partial class Attachment
{
public string Id { get; }
public string Url { get; }
public int? Width { get; }
public int? Height { get; }
public string FileName { get; }
public bool IsImage { get; }
public FileSize FileSize { get; }
public Attachment(string id, int? width, int? height, string url, string fileName, FileSize fileSize)
{
Id = id;
Url = url;
Width = width;
Height = height;
FileName = fileName;
FileSize = fileSize;
IsImage = GetIsImage(fileName);
}
public override string ToString() => FileName;
}
public partial class Attachment
{
private static readonly string[] ImageFileExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".bmp" };
public static bool GetIsImage(string fileName)
{
var fileExtension = Path.GetExtension(fileName);
return ImageFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase);
}
}
}
@@ -6,6 +6,8 @@
{
public string Id { get; }
public string ParentId { get; }
public string GuildId { get; }
public string Name { get; }
@@ -14,9 +16,10 @@
public ChannelType Type { get; }
public Channel(string id, string guildId, string name, string topic, ChannelType type)
public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type)
{
Id = id;
ParentId = parentId;
GuildId = guildId;
Name = name;
Topic = topic;
@@ -29,6 +32,6 @@
public partial class Channel
{
public static Channel CreateDeletedChannel(string id) =>
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
new Channel(id, null, null, "deleted-channel", null, ChannelType.GuildTextChat);
}
}
@@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class ChatLog
{
public Guild Guild { get; }
public Channel Channel { get; }
public DateTimeOffset? After { get; }
public DateTimeOffset? Before { get; }
public IReadOnlyList<Message> Messages { get; }
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTimeOffset? after, DateTimeOffset? before,
IReadOnlyList<Message> messages, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
After = after;
Before = before;
Messages = messages;
Mentionables = mentionables;
}
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
</ItemGroup>
</Project>
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Core.Models
public string Url { get; }
public DateTime? Timestamp { get; }
public DateTimeOffset? Timestamp { get; }
public Color Color { get; }
@@ -28,7 +28,7 @@ namespace DiscordChatExporter.Core.Models
public EmbedFooter Footer { get; }
public Embed(string title, string url, DateTime? timestamp, Color color, EmbedAuthor author, string description,
public Embed(string title, string url, DateTimeOffset? timestamp, Color color, EmbedAuthor author, string description,
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
{
Title = title;
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Linq;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji
{
public string Id { get; }
public string Name { get; }
public bool IsAnimated { get; }
public string ImageUrl { get; }
public Emoji(string id, string name, bool isAnimated)
{
Id = id;
Name = name;
IsAnimated = isAnimated;
ImageUrl = GetImageUrl(id, name, isAnimated);
}
}
public partial class Emoji
{
private static IEnumerable<int> GetCodePoints(string emoji)
{
for (var i = 0; i < emoji.Length; i += char.IsHighSurrogate(emoji[i]) ? 2 : 1)
yield return char.ConvertToUtf32(emoji, i);
}
private static string GetTwemojiName(string emoji) =>
GetCodePoints(emoji).Select(i => i.ToString("x")).JoinToString("-");
public static string GetImageUrl(string id, string name, bool isAnimated)
{
// Custom emoji
if (!id.IsNullOrWhiteSpace())
{
// Animated
if (isAnimated)
return $"https://cdn.discordapp.com/emojis/{id}.gif";
// Non-animated
return $"https://cdn.discordapp.com/emojis/{id}.png";
}
// Standard unicode emoji (via twemoji)
var twemojiName = GetTwemojiName(name);
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
}
}
}
@@ -0,0 +1,84 @@
using System;
namespace DiscordChatExporter.Core.Models
{
// Loosely based on https://github.com/omar/ByteSize (MIT license)
public struct FileSize
{
public const long BytesInKiloByte = 1024;
public const long BytesInMegaByte = 1024 * BytesInKiloByte;
public const long BytesInGigaByte = 1024 * BytesInMegaByte;
public const long BytesInTeraByte = 1024 * BytesInGigaByte;
public const long BytesInPetaByte = 1024 * BytesInTeraByte;
public const string ByteSymbol = "B";
public const string KiloByteSymbol = "KB";
public const string MegaByteSymbol = "MB";
public const string GigaByteSymbol = "GB";
public const string TeraByteSymbol = "TB";
public const string PetaByteSymbol = "PB";
public double Bytes { get; }
public double KiloBytes => Bytes / BytesInKiloByte;
public double MegaBytes => Bytes / BytesInMegaByte;
public double GigaBytes => Bytes / BytesInGigaByte;
public double TeraBytes => Bytes / BytesInTeraByte;
public double PetaBytes => Bytes / BytesInPetaByte;
public string LargestWholeNumberSymbol
{
get
{
// Absolute value is used to deal with negative values
if (Math.Abs(PetaBytes) >= 1)
return PetaByteSymbol;
if (Math.Abs(TeraBytes) >= 1)
return TeraByteSymbol;
if (Math.Abs(GigaBytes) >= 1)
return GigaByteSymbol;
if (Math.Abs(MegaBytes) >= 1)
return MegaByteSymbol;
if (Math.Abs(KiloBytes) >= 1)
return KiloByteSymbol;
return ByteSymbol;
}
}
public double LargestWholeNumberValue
{
get
{
// Absolute value is used to deal with negative values
if (Math.Abs(PetaBytes) >= 1)
return PetaBytes;
if (Math.Abs(TeraBytes) >= 1)
return TeraBytes;
if (Math.Abs(GigaBytes) >= 1)
return GigaBytes;
if (Math.Abs(MegaBytes) >= 1)
return MegaBytes;
if (Math.Abs(KiloBytes) >= 1)
return KiloBytes;
return Bytes;
}
}
public FileSize(double bytes)
{
Bytes = bytes;
}
public override string ToString() => $"{LargestWholeNumberValue:0.##} {LargestWholeNumberSymbol}";
}
}
@@ -12,15 +12,15 @@ namespace DiscordChatExporter.Core.Models
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 string IconUrl { get; }
public Guild(string id, string name, string iconHash)
{
Id = id;
Name = name;
IconHash = iconHash;
IconUrl = GetIconUrl(id, iconHash);
}
public override string ToString() => Name;
@@ -28,6 +28,13 @@ namespace DiscordChatExporter.Core.Models
public partial class Guild
{
public static string GetIconUrl(string id, string iconHash)
{
return !iconHash.IsNullOrWhiteSpace()
? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
}
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
}
}
@@ -15,9 +15,9 @@ namespace DiscordChatExporter.Core.Models
public User Author { get; }
public DateTime Timestamp { get; }
public DateTimeOffset Timestamp { get; }
public DateTime? EditedTimestamp { get; }
public DateTimeOffset? EditedTimestamp { get; }
public string Content { get; }
@@ -29,8 +29,8 @@ namespace DiscordChatExporter.Core.Models
public IReadOnlyList<User> MentionedUsers { get; }
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
public Message(string id, string channelId, MessageType type, User author, DateTimeOffset timestamp,
DateTimeOffset? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
{
Id = id;
@@ -6,15 +6,12 @@
{
public int Count { get; }
public string EmojiId { get; }
public Emoji Emoji { get; }
public string EmojiName { get; }
public Reaction(int count, string emojiId, string emojiName)
public Reaction(int count, Emoji emoji)
{
Count = count;
EmojiId = emojiId;
EmojiName = emojiName;
Emoji = emoji;
}
}
}
@@ -19,7 +19,6 @@
public partial class Role
{
public static Role CreateDeletedRole(string id) =>
new Role(id, "deleted-role");
public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role");
}
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{
// https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class User
{
public string Id { get; }
public int Discriminator { get; }
public string Name { get; }
public string FullName { get; }
public string AvatarHash { get; }
public string AvatarUrl { get; }
public bool IsBot { get; }
public User(string id, int discriminator, string name, string avatarHash, bool isBot)
{
Id = id;
Discriminator = discriminator;
Name = name;
AvatarHash = avatarHash;
IsBot = isBot;
FullName = GetFullName(name, discriminator);
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
}
public override string ToString() => FullName;
}
public partial class User
{
public static string GetFullName(string name, int discriminator) => $"{name}#{discriminator:0000}";
public static string GetAvatarUrl(string id, int discriminator, string avatarHash)
{
// Custom avatar
if (!avatarHash.IsNullOrWhiteSpace())
{
// Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
// Non-animated
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
}
// Default avatar
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
}
public static User CreateUnknownUser(string id) => new User(id, 0, "Unknown", null, false);
}
}
@@ -0,0 +1,113 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public class CsvChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _dateFormat;
public CsvChatLogRenderer(ChatLog chatLog, string dateFormat)
{
_chatLog = chatLog;
_dateFormat = dateFormat;
}
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private string FormatMarkdown(Node node)
{
// Formatted node
if (node is FormattedNode formattedNode)
{
// Recursively get inner text
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
}
// Non-meta mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
{
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
return $"@{user.Name}";
}
// Channel mention node
if (mentionNode.Type == MentionType.Channel)
{
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
return $"#{channel.Name}";
}
// Role mention node
if (mentionNode.Type == MentionType.Role)
{
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
return $"@{role.Name}";
}
}
// Custom emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
{
return $":{emojiNode.Name}:";
}
// All other nodes - simply return source
return node.Source;
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
private async Task RenderFieldAsync(TextWriter writer, string value)
{
var encodedValue = value.Replace("\"", "\"\"");
await writer.WriteAsync($"\"{encodedValue}\";");
}
private async Task RenderMessageAsync(TextWriter writer, Message message)
{
// Author
await RenderFieldAsync(writer, message.Author.FullName);
// Timestamp
await RenderFieldAsync(writer, FormatDate(message.Timestamp));
// Content
await RenderFieldAsync(writer, FormatMarkdown(message.Content));
// Attachments
var formattedAttachments = message.Attachments.Select(a => a.Url).JoinToString(",");
await RenderFieldAsync(writer, formattedAttachments);
// Line break
await writer.WriteLineAsync();
}
public async Task RenderAsync(TextWriter writer)
{
// Headers
await writer.WriteLineAsync("Author;Date;Content;Attachments;");
// Log
foreach (var message in _chatLog.Messages)
await RenderMessageAsync(writer, message);
}
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\HtmlDark.css" />
<EmbeddedResource Include="Resources\HtmlDark.html" />
<EmbeddedResource Include="Resources\HtmlLight.css" />
<EmbeddedResource Include="Resources\HtmlLight.html" />
<EmbeddedResource Include="Resources\HtmlShared.css" />
<EmbeddedResource Include="Resources\HtmlShared.html" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Scriban" Version="2.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlChatLogRenderer
{
private class MessageGroup
{
public User Author { get; }
public DateTimeOffset Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Messages = messages;
}
}
}
}
@@ -0,0 +1,27 @@
using System.Reflection;
using System.Threading.Tasks;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlChatLogRenderer
{
private class TemplateLoader : ITemplateLoader
{
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Rendering.Resources";
public string Load(string templatePath) =>
Assembly.GetExecutingAssembly().GetManifestResourceString($"{ResourceRootNamespace}.{templatePath}");
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) => templateName;
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) => Load(templatePath);
public ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath) =>
new ValueTask<string>(Load(templatePath));
}
}
}
@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _themeName;
private readonly string _dateFormat;
public HtmlChatLogRenderer(ChatLog chatLog, string themeName, string dateFormat)
{
_chatLog = chatLog;
_themeName = themeName;
_dateFormat = dateFormat;
}
private string HtmlEncode(string s) => WebUtility.HtmlEncode(s);
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages) =>
messages.GroupContiguous((buffer, message) =>
{
// Break group if the author changed
if (buffer.Last().Author.Id != message.Author.Id)
return false;
// Break group if last message was more than 7 minutes ago
if ((message.Timestamp - buffer.Last().Timestamp).TotalMinutes > 7)
return false;
return true;
}).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
private string FormatMarkdown(Node node, bool isJumbo)
{
// Text node
if (node is TextNode textNode)
{
// Return HTML-encoded text
return HtmlEncode(textNode.Text);
}
// Formatted node
if (node is FormattedNode formattedNode)
{
// Recursively get inner html
var innerHtml = FormatMarkdown(formattedNode.Children, false);
// Bold
if (formattedNode.Formatting == TextFormatting.Bold)
return $"<strong>{innerHtml}</strong>";
// Italic
if (formattedNode.Formatting == TextFormatting.Italic)
return $"<em>{innerHtml}</em>";
// Underline
if (formattedNode.Formatting == TextFormatting.Underline)
return $"<u>{innerHtml}</u>";
// Strikethrough
if (formattedNode.Formatting == TextFormatting.Strikethrough)
return $"<s>{innerHtml}</s>";
// Spoiler
if (formattedNode.Formatting == TextFormatting.Spoiler)
return $"<span class=\"spoiler\">{innerHtml}</span>";
}
// Inline code block node
if (node is InlineCodeBlockNode inlineCodeBlockNode)
{
return $"<span class=\"pre pre--inline\">{HtmlEncode(inlineCodeBlockNode.Code)}</span>";
}
// Multi-line code block node
if (node is MultilineCodeBlockNode multilineCodeBlockNode)
{
// Set language class for syntax highlighting
var languageCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace()
? "language-" + multilineCodeBlockNode.Language
: null;
return $"<div class=\"pre pre--multiline {languageCssClass}\">{HtmlEncode(multilineCodeBlockNode.Code)}</div>";
}
// Mention node
if (node is MentionNode mentionNode)
{
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return $"<span class=\"mention\">@{HtmlEncode(mentionNode.Id)}</span>";
}
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
return $"<span class=\"mention\" title=\"{HtmlEncode(user.FullName)}\">@{HtmlEncode(user.Name)}</span>";
}
// Channel mention node
if (mentionNode.Type == MentionType.Channel)
{
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
return $"<span class=\"mention\">#{HtmlEncode(channel.Name)}</span>";
}
// Role mention node
if (mentionNode.Type == MentionType.Role)
{
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
return $"<span class=\"mention\">@{HtmlEncode(role.Name)}</span>";
}
}
// Emoji node
if (node is EmojiNode emojiNode)
{
// Get emoji image URL
var emojiImageUrl = Emoji.GetImageUrl(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated);
// Make emoji large if it's jumbo
var jumboableCssClass = isJumbo ? "emoji--large" : null;
return $"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />";
}
// Link node
if (node is LinkNode linkNode)
{
return $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\">{HtmlEncode(linkNode.Title)}</a>";
}
// All other nodes - simply return source
return node.Source;
}
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
{
// Emojis are jumbo if all top-level nodes are emoji nodes, disregarding whitespace
var isJumbo = isTopLevel && nodes.Where(n => !n.Source.IsNullOrWhiteSpace()).All(n => n is EmojiNode);
return nodes.Select(n => FormatMarkdown(n, isJumbo)).JoinToString("");
}
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown), true);
public async Task RenderAsync(TextWriter writer)
{
// Create template loader
var loader = new TemplateLoader();
// Get template
var templateCode = loader.Load($"Html{_themeName}.html");
var template = Template.Parse(templateCode);
// Create template context
var context = new TemplateContext
{
TemplateLoader = loader,
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
// Create template model
var model = new ScriptObject();
model.SetValue("Model", _chatLog, true);
model.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
model.Import(nameof(FormatDate), new Func<DateTimeOffset, string>(FormatDate));
model.Import(nameof(FormatMarkdown), new Func<string, string>(FormatMarkdown));
context.PushGlobal(model);
// Configure output
context.PushOutput(new TextWriterOutput(writer));
// HACK: Render output in a separate thread
// (even though Scriban has async API, it still makes a lot of blocking CPU-bound calls)
await Task.Run(async () => await context.EvaluateAsync(template.Page));
}
}
}
@@ -0,0 +1,10 @@
using System.IO;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Rendering
{
public interface IChatLogRenderer
{
Task RenderAsync(TextWriter writer);
}
}
@@ -0,0 +1,129 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public class PlainTextChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _dateFormat;
public PlainTextChatLogRenderer(ChatLog chatLog, string dateFormat)
{
_chatLog = chatLog;
_dateFormat = dateFormat;
}
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private string FormatDateRange(DateTimeOffset? after, DateTimeOffset? before)
{
// Both 'after' and 'before'
if (after != null && before != null)
return $"{FormatDate(after.Value)} to {FormatDate(before.Value)}";
// Just 'after'
if (after != null)
return $"after {FormatDate(after.Value)}";
// Just 'before'
if (before != null)
return $"before {FormatDate(before.Value)}";
// Neither
return null;
}
private string FormatMarkdown(Node node)
{
// Formatted node
if (node is FormattedNode formattedNode)
{
// Recursively get inner text
var innerText = FormatMarkdown(formattedNode.Children);
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
}
// Non-meta mention node
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
{
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
return $"@{user.Name}";
}
// Channel mention node
if (mentionNode.Type == MentionType.Channel)
{
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
return $"#{channel.Name}";
}
// Role mention node
if (mentionNode.Type == MentionType.Role)
{
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
return $"@{role.Name}";
}
}
// Custom emoji node
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
{
return $":{emojiNode.Name}:";
}
// All other nodes - simply return source
return node.Source;
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
private async Task RenderMessageAsync(TextWriter writer, Message message)
{
// Timestamp and author
await writer.WriteLineAsync($"[{FormatDate(message.Timestamp)}] {message.Author.FullName}");
// Content
await writer.WriteLineAsync(FormatMarkdown(message.Content));
// Attachments
foreach (var attachment in message.Attachments)
await writer.WriteLineAsync(attachment.Url);
}
public async Task RenderAsync(TextWriter writer)
{
// Metadata
await writer.WriteLineAsync('='.Repeat(62));
await writer.WriteLineAsync($"Guild: {_chatLog.Guild.Name}");
await writer.WriteLineAsync($"Channel: {_chatLog.Channel.Name}");
await writer.WriteLineAsync($"Topic: {_chatLog.Channel.Topic}");
await writer.WriteLineAsync($"Messages: {_chatLog.Messages.Count:N0}");
await writer.WriteLineAsync($"Range: {FormatDateRange(_chatLog.After, _chatLog.Before)}");
await writer.WriteLineAsync('='.Repeat(62));
await writer.WriteLineAsync();
// Log
foreach (var message in _chatLog.Messages)
{
await RenderMessageAsync(writer, message);
await writer.WriteLineAsync();
}
}
}
}
@@ -2,24 +2,27 @@
body {
background-color: #36393e;
color: #ffffffb3;
color: #dcddde;
}
a {
color: #0096cf;
}
.spoiler {
background-color: rgba(255, 255, 255, 0.1);
}
.pre {
background-color: #2f3136;
background-color: #2f3136 !important;
}
.pre--multiline {
border-color: #282b30;
color: #839496;
border-color: #282b30 !important;
color: #839496 !important;
}
.mention {
background-color: #738bd71a;
color: #7289da;
}
@@ -40,7 +43,7 @@ a {
/* === CHATLOG === */
.chatlog__message-group {
border-color: #ffffff0a;
border-color: rgba(255, 255, 255, 0.1);
}
.chatlog__author-name {
@@ -48,16 +51,16 @@ a {
}
.chatlog__timestamp {
color: #ffffff33;
color: rgba(255, 255, 255, 0.2);
}
.chatlog__edited-timestamp {
color: #ffffff33;
color: rgba(255, 255, 255, 0.2);
}
.chatlog__embed-content-container {
background-color: #2e30364d;
border-color: #2e303699;
background-color: rgba(46, 48, 54, 0.3);
border-color: rgba(46, 48, 54, 0.6);
}
.chatlog__embed-author-name {
@@ -73,7 +76,7 @@ a {
}
.chatlog__embed-description {
color: #ffffff99;
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-field-name {
@@ -81,17 +84,17 @@ a {
}
.chatlog__embed-field-value {
color: #ffffff99;
color: rgba(255, 255, 255, 0.6);
}
.chatlog__embed-footer {
color: #ffffff99;
color: rgba(255, 255, 255, 0.6);
}
.chatlog__reaction {
background-color: #ffffff0a;
background-color: rgba(255, 255, 255, 0.05);
}
.chatlog__reaction-count {
color: #ffffff4d;
color: rgba(255, 255, 255, 0.3);
}
@@ -0,0 +1,3 @@
{{~ ThemeStyleSheet = include "HtmlDark.css" ~}}
{{~ HighlightJsStyleName = "solarized-dark" ~}}
{{~ include "HtmlShared.html" ~}}
@@ -2,20 +2,24 @@
body {
background-color: #ffffff;
color: #737f8d;
color: #747f8d;
}
a {
color: #00b0f4;
}
.spoiler {
background-color: rgba(0, 0, 0, 0.1);
}
.pre {
background-color: #f9f9f9;
background-color: #f9f9f9 !important;
}
.pre--multiline {
border-color: #f3f3f3;
color: #657b83;
border-color: #f3f3f3 !important;
color: #657b83 !important;
}
.mention {
@@ -56,8 +60,8 @@ a {
}
.chatlog__embed-content-container {
background-color: #f9f9f94d;
border-color: #cccccc4d;
background-color: rgba(249, 249, 249, 0.3);
border-color: rgba(204, 204, 204, 0.3);
}
.chatlog__embed-author-name {
@@ -85,11 +89,11 @@ a {
}
.chatlog__embed-footer {
color: #4f535b99;
color: rgba(79, 83, 91, 0.4);
}
.chatlog__reaction {
background-color: #4f545c0f;
background-color: rgba(79, 84, 92, 0.06);
}
.chatlog__reaction-count {
@@ -0,0 +1,3 @@
{{~ ThemeStyleSheet = include "HtmlLight.css" ~}}
{{~ HighlightJsStyleName = "solarized-light" ~}}
{{~ include "HtmlShared.html" ~}}
@@ -1,5 +1,35 @@
/* === GENERAL === */
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
font-weight: 300;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
font-weight: 400;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
font-weight: 500;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
font-weight: 600;
}
@font-face {
font-family: Whitney;
src: url(https://discordapp.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
font-weight: 700;
}
body {
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
@@ -17,9 +47,18 @@ img {
object-fit: contain;
}
.markdown {
white-space: pre-wrap;
line-height: 1.3;
overflow-wrap: break-word;
}
.spoiler {
border-radius: 3px;
}
.pre {
font-family: "Consolas", "Courier New", Courier, Monospace;
white-space: pre-wrap;
}
.pre--multiline {
@@ -32,27 +71,28 @@ img {
.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;
font-size: 85%;
}
.mention {
font-weight: 600;
font-weight: 500;
}
.emoji {
width: 1.45em;
height: 1.45em;
margin: 0 1px;
vertical-align: -0.4em;
}
.emoji--small {
width: 1rem;
height: 1rem;
}
.emoji--large {
width: 2rem;
height: 2rem;
}
/* === INFO === */
@@ -101,6 +141,7 @@ img {
.chatlog {
max-width: 100%;
margin-bottom: 24px;
}
.chatlog__message-group {
@@ -130,6 +171,7 @@ img {
.chatlog__author-name {
font-size: 1em;
font-weight: 500;
}
.chatlog__timestamp {
@@ -144,7 +186,7 @@ img {
}
.chatlog__edited-timestamp {
margin-left: 5px;
margin-left: 3px;
font-size: .8em;
}
@@ -278,26 +320,37 @@ img {
}
.chatlog__embed-footer-text {
font-weight: 600;
font-weight: 500;
font-size: .75em;
}
.chatlog__reactions {
display: flex;
display: flex;
}
.chatlog__reaction {
display: flex;
align-items: center;
margin: 6px 2px 2px 2px;
padding: 2px 6px 2px 2px;
padding: 3px 6px;
border-radius: 3px;
}
.chatlog__reaction-emoji {
margin-left: 3px;
vertical-align: middle;
.chatlog__reaction-count {
min-width: 9px;
margin-left: 6px;
font-size: .875em;
}
.chatlog__reaction-count {
font-size: .875em;
.chatlog__bot-tag {
margin-left: 0.3em;
background: #7289da;
color: #ffffff;
font-size: 0.625em;
padding: 1px 2px;
border-radius: 3px;
vertical-align: middle;
line-height: 1.3;
position: relative;
top: -.2em;
}
@@ -2,12 +2,29 @@
<html lang="en">
<head>
{{~ # Metadata ~}}
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
{{~ # Styles ~}}
<style>
{{ StyleSheet }}
{{ include "HtmlShared.css" }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach((block) => {
hljs.highlightBlock(block);
});
});
</script>
</head>
<body>
@@ -24,16 +41,16 @@
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{~ end ~}}
<div class="info__channel-message-count">{{ Model.TotalMessageCount | Format "N0" }} messages</div>
<div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div>
{{~ if Model.From || Model.To ~}}
{{~ if Model.After || Model.Before ~}}
<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 }}
{{~ if Model.After && Model.Before ~}}
Between {{ Model.After | FormatDate | html.escape }} and {{ Model.Before | FormatDate | html.escape }}
{{~ else if Model.After ~}}
After {{ Model.After | FormatDate | html.escape }}
{{~ else if Model.Before ~}}
Before {{ Model.Before | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
@@ -42,7 +59,7 @@
{{~ # Log ~}}
<div class="chatlog">
{{~ for group in Model.MessageGroups ~}}
{{~ for group in Model.Messages | GroupMessages ~}}
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
@@ -50,7 +67,10 @@
</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__author-name" title="{{ group.Author.FullName | html.escape }}" data-user-id="{{ group.Author.Id | html.escape }}">{{ group.Author.Name | html.escape }}</span>
{{~ if group.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span>
{{~ end ~}}
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
@@ -58,7 +78,7 @@
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
{{ message.Content | FormatContent }}
<span class="markdown">{{ message.Content | FormatMarkdown }}</span>
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
@@ -76,7 +96,7 @@
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
</div>
@@ -85,7 +105,7 @@
{{~ # 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-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
@@ -112,16 +132,16 @@
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
{{~ else ~}}
{{ embed.Title | FormatContent }}
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ # Fields ~}}
@@ -130,10 +150,10 @@
{{~ 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>
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
{{~ end ~}}
</div>
{{~ end ~}}
@@ -192,15 +212,7 @@
<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>
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}" />
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
@@ -1,8 +1,8 @@
using System;
using System.Drawing;
using System.Linq;
using DiscordChatExporter.Core.Internal;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services.Internal;
using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions;
@@ -16,8 +16,9 @@ namespace DiscordChatExporter.Core.Services
var discriminator = json["discriminator"].Value<int>();
var name = json["username"].Value<string>();
var avatarHash = json["avatar"].Value<string>();
var isBot = json["bot"]?.Value<bool>() ?? false;
return new User(id, discriminator, name, avatarHash);
return new User(id, discriminator, name, avatarHash, isBot);
}
private Guild ParseGuild(JToken json)
@@ -33,6 +34,7 @@ namespace DiscordChatExporter.Core.Services
{
// Get basic data
var id = json["id"].Value<string>();
var parentId = json["parent_id"]?.Value<string>();
var type = (ChannelType) json["type"].Value<int>();
var topic = json["topic"]?.Value<string>();
@@ -40,17 +42,17 @@ namespace DiscordChatExporter.Core.Services
var guildId = json["guild_id"]?.Value<string>();
// If the guild ID is blank, it's direct messages
if (guildId.IsBlank())
if (guildId.IsNullOrWhiteSpace())
guildId = Guild.DirectMessages.Id;
// Try to extract name
var name = json["name"]?.Value<string>();
// If the name is blank, it's direct messages
if (name.IsBlank())
if (name.IsNullOrWhiteSpace())
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
return new Channel(id, guildId, name, topic, type);
return new Channel(id, parentId, guildId, name, topic, type);
}
private Role ParseRole(JToken json)
@@ -68,7 +70,9 @@ namespace DiscordChatExporter.Core.Services
var width = json["width"]?.Value<int>();
var height = json["height"]?.Value<int>();
var fileName = json["filename"].Value<string>();
var fileSize = json["size"].Value<long>();
var fileSizeBytes = json["size"].Value<long>();
var fileSize = new FileSize(fileSizeBytes);
return new Attachment(id, width, height, url, fileName, fileSize);
}
@@ -114,7 +118,7 @@ namespace DiscordChatExporter.Core.Services
var title = json["title"]?.Value<string>();
var description = json["description"]?.Value<string>();
var url = json["url"]?.Value<string>();
var timestamp = json["timestamp"]?.Value<DateTime>();
var timestamp = json["timestamp"]?.Value<DateTime>().ToDateTimeOffset();
// Get color
var color = json["color"] != null
@@ -139,13 +143,21 @@ namespace DiscordChatExporter.Core.Services
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
}
private Emoji ParseEmoji(JToken json)
{
var id = json["id"]?.Value<string>();
var name = json["name"]?.Value<string>();
var isAnimated = json["animated"]?.Value<bool>() ?? false;
return new Emoji(id, name, isAnimated);
}
private Reaction ParseReaction(JToken json)
{
var count = json["count"].Value<int>();
var emojiId = json["emoji"]["id"]?.Value<string>();
var emojiName = json["emoji"]["name"].Value<string>();
var emoji = ParseEmoji(json["emoji"]);
return new Reaction(count, emojiId, emojiName);
return new Reaction(count, emoji);
}
private Message ParseMessage(JToken json)
@@ -153,8 +165,8 @@ namespace DiscordChatExporter.Core.Services
// 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 timestamp = json["timestamp"].Value<DateTime>().ToDateTimeOffset();
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>()?.ToDateTimeOffset();
var content = json["content"].Value<string>();
var type = (MessageType) json["type"].Value<int>();
@@ -4,29 +4,31 @@ 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 DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Internal;
using Failsafe;
using Newtonsoft.Json.Linq;
using DiscordChatExporter.Core.Internal;
using Polly;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public partial class DataService : IDataService, IDisposable
public partial class DataService : IDisposable
{
private readonly HttpClient _httpClient = new HttpClient();
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
params string[] parameters)
{
// Create request policy
var policy = Policy
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
// Create retry policy
var retry = Retry.Create()
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode >= 500)
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode == 429)
.WithMaxTryCount(10)
.WithDelay(TimeSpan.FromSeconds(0.4));
// Send request
return await policy.ExecuteAsync(async () =>
return await retry.ExecuteAsync(async () =>
{
// Create request
const string apiRoot = "https://discordapp.com/api/v6";
@@ -42,6 +44,11 @@ namespace DiscordChatExporter.Core.Services
{
var key = parameter.SubstringUntil("=");
var value = parameter.SubstringAfter("=");
// Skip empty values
if (value.IsNullOrWhiteSpace())
continue;
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
}
@@ -65,6 +72,10 @@ namespace DiscordChatExporter.Core.Services
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);
@@ -112,31 +123,24 @@ namespace DiscordChatExporter.Core.Services
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
DateTimeOffset? after = null, DateTimeOffset? before = 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}");
"limit=1", $"before={before?.ToSnowflake()}");
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
// If the last message doesn't exist or it's outside of range - return
if (lastMessage == null || lastMessage.Timestamp < from)
if (lastMessage == null || lastMessage.Timestamp < after)
{
progress?.Report(1);
return result;
}
// Get other messages
var offsetId = firstId;
var offsetId = after?.ToSnowflake() ?? "0";
while (true)
{
// Get message batch
@@ -210,6 +214,40 @@ namespace DiscordChatExporter.Core.Services
return new Mentionables(users, channels, roles);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
{
// Get messages
var messages = await GetChannelMessagesAsync(token, channel.Id, after, before, progress);
// Get mentionables
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
return new ChatLog(guild, channel, after, before, messages, mentionables);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Channel channel,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
{
// Get guild
var guild = channel.GuildId == Guild.DirectMessages.Id
? Guild.DirectMessages
: await GetGuildAsync(token, channel.GuildId);
// Get the chat log
return await GetChatLogAsync(token, guild, channel, after, before, progress);
}
public async Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
{
// Get channel
var channel = await GetChannelAsync(token, channelId);
// Get the chat log
return await GetChatLogAsync(token, channel, after, before, progress);
}
public void Dispose()
{
_httpClient.Dispose();
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Failsafe" Version="1.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<PackageReference Include="Onova" Version="2.4.2" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.1" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
<ProjectReference Include="..\DiscordChatExporter.Core.Rendering\DiscordChatExporter.Core.Rendering.csproj" />
</ItemGroup>
</Project>
@@ -1,7 +1,7 @@
using System;
using System.Net;
namespace DiscordChatExporter.Core.Exceptions
namespace DiscordChatExporter.Core.Services.Exceptions
{
public class HttpErrorStatusCodeException : Exception
{
@@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Rendering;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services
{
public class ExportService
{
private readonly SettingsService _settingsService;
public ExportService(SettingsService settingsService)
{
_settingsService = settingsService;
}
private IChatLogRenderer CreateRenderer(ChatLog chatLog, ExportFormat format)
{
if (format == ExportFormat.PlainText)
return new PlainTextChatLogRenderer(chatLog, _settingsService.DateFormat);
if (format == ExportFormat.HtmlDark)
return new HtmlChatLogRenderer(chatLog, "Dark", _settingsService.DateFormat);
if (format == ExportFormat.HtmlLight)
return new HtmlChatLogRenderer(chatLog, "Light", _settingsService.DateFormat);
if (format == ExportFormat.Csv)
return new CsvChatLogRenderer(chatLog, _settingsService.DateFormat);
throw new ArgumentOutOfRangeException(nameof(format), $"Unknown format [{format}].");
}
private async Task ExportChatLogAsync(ChatLog chatLog, string filePath, ExportFormat format)
{
// Create output directory
var dirPath = Path.GetDirectoryName(filePath);
if (!dirPath.IsNullOrWhiteSpace())
Directory.CreateDirectory(dirPath);
// Render chat log to output file
using (var writer = File.CreateText(filePath))
await CreateRenderer(chatLog, format).RenderAsync(writer);
}
public async Task ExportChatLogAsync(ChatLog chatLog, string filePath, ExportFormat format, int? partitionLimit)
{
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
if (partitionLimit == null || partitionLimit <= 0 || chatLog.Messages.Count <= partitionLimit)
{
await ExportChatLogAsync(chatLog, filePath, format);
}
// Otherwise split into partitions and export separately
else
{
// Create partitions by grouping up to X contiguous messages into separate chat logs
var partitions = chatLog.Messages.GroupContiguous(g => g.Count < partitionLimit.Value)
.Select(g => new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.After, chatLog.Before, g, chatLog.Mentionables))
.ToArray();
// Split file path into components
var dirPath = Path.GetDirectoryName(filePath);
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
var fileExt = Path.GetExtension(filePath);
// Export each partition separately
var partitionNumber = 1;
foreach (var partition in partitions)
{
// Compose new file name
var partitionFilePath = $"{fileNameWithoutExt} [{partitionNumber} of {partitions.Length}]{fileExt}";
// Compose full file path
if (!dirPath.IsNullOrWhiteSpace())
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
// Export
await ExportChatLogAsync(partition, partitionFilePath, format);
// Increment partition number
partitionNumber++;
}
}
}
}
}
@@ -0,0 +1,59 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services.Helpers
{
public static class ExportHelper
{
public static bool IsDirectoryPath(string path) =>
path.Last() == Path.DirectorySeparatorChar ||
path.Last() == Path.AltDirectorySeparatorChar ||
(Path.GetExtension(path).IsNullOrWhiteSpace() && !File.Exists(path));
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
DateTimeOffset? after = null, DateTimeOffset? before = null)
{
var result = new StringBuilder();
// Append guild and channel names
result.Append($"{guild.Name} - {channel.Name} [{channel.Id}]");
// Append date range
if (after != null || before != null)
{
result.Append(" (");
// Both 'after' and 'before' are set
if (after != null && before != null)
{
result.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
}
// Only 'after' is set
else if (after != null)
{
result.Append($"after {after:yyyy-MM-dd}");
}
// Only 'before' is set
else
{
result.Append($"before {before:yyyy-MM-dd}");
}
result.Append(")");
}
// Append extension
result.Append($".{format.GetFileExtension()}");
// Replace invalid chars
foreach (var invalidChar in Path.GetInvalidFileNameChars())
result.Replace(invalidChar, '_');
return result.ToString();
}
}
}
@@ -0,0 +1,18 @@
using System;
using System.Drawing;
namespace DiscordChatExporter.Core.Services.Internal
{
internal static class Extensions
{
public static DateTimeOffset ToDateTimeOffset(this DateTime dateTime) => new DateTimeOffset(dateTime);
public static string ToSnowflake(this DateTimeOffset dateTime)
{
var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
return value.ToString();
}
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
}
}
@@ -3,15 +3,15 @@ using Tyrrrz.Settings;
namespace DiscordChatExporter.Core.Services
{
public class SettingsService : SettingsManager, ISettingsService
public class SettingsService : SettingsManager
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20;
public AuthToken LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public int? LastPartitionLimit { get; set; }
public SettingsService()
{
@@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net461</TargetFramework>
<Version>2.7</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>
@@ -1,23 +0,0 @@
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);
}
}
@@ -1,33 +0,0 @@
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;
}
}
@@ -1,36 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
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<MessageGroup> MessageGroups { get; }
public int TotalMessageCount => MessageGroups.Sum(g => g.Messages.Count);
public Mentionables Mentionables { get; }
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
IReadOnlyList<MessageGroup> messageGroups, Mentionables mentionables)
{
Guild = guild;
Channel = channel;
From = from;
To = to;
MessageGroups = messageGroups;
Mentionables = mentionables;
}
public override string ToString() => $"{Guild.Name} | {Channel.Name}";
}
}
@@ -1,23 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Models
{
public class MessageGroup
{
public User Author { get; }
public DateTime Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Messages = messages;
}
public override string ToString() => $"{Author.FullName} | {Timestamp} | {Messages.Count} messages";
}
}
-61
View File
@@ -1,61 +0,0 @@
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);
}
}
@@ -1,12 +0,0 @@
Author;Date;Content;Attachments;
{{~ for group in Model.MessageGroups -}}
{{- for message in group.Messages -}}
{{- message.Author.FullName }};
{{- message.Timestamp | FormatDate }};
{{- message.Content | FormatContent }};
{{- message.Attachments | array.map "Url" | array.join "," }};
{{~ end -}}
{{- end -}}
Can't render this file because it contains an unexpected character in line 10 and column 41.
@@ -1,7 +0,0 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.DarkTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -1,7 +0,0 @@
{{
$SharedStyleSheet = include "Html.Shared.css"
$ThemeStyleSheet = include "Html.LightTheme.css"
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
}}
{{ include "Html.Core.html" }}
@@ -1,25 +0,0 @@
{{~ # Info ~}}
==============================================================
Guild: {{ Model.Guild.Name }}
Channel: {{ Model.Channel.Name }}
Topic: {{ Model.Channel.Topic }}
Messages: {{ Model.TotalMessageCount | 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 group in Model.MessageGroups ~}}
{{~ # Author name and timestamp ~}}
{{~ }}[{{ group.Timestamp | FormatDate }}] {{ group.Author.FullName }}
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
{{~ # Content ~}}
{{~ message.Content | FormatContent }}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
{{~ attachment.Url }}
{{~ end ~}}
{{~ end ~}}
{{~ end ~}}
@@ -1,37 +0,0 @@
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));
}
}
}
}
@@ -1,323 +0,0 @@
using System;
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;
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat)
{
_format = format;
_log = log;
_dateFormat = dateFormat;
}
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)
{
// New lines
content = content.Replace("\n", ", ");
// 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(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;
}
}
}
}
@@ -1,57 +0,0 @@
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;
}
public void Export(ExportFormat format, string filePath, ChatLog log)
{
// 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, log, _settingsService.DateFormat);
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 template
template.Render(context);
}
}
}
}
@@ -1,28 +0,0 @@
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);
}
}
@@ -1,9 +0,0 @@
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IExportService
{
void Export(ExportFormat format, string filePath, ChatLog log);
}
}
@@ -1,10 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Services
{
public interface IMessageGroupService
{
IReadOnlyList<MessageGroup> GroupMessages(IEnumerable<Message> messages);
}
}
@@ -1,18 +0,0 @@
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; }
void Load();
void Save();
}
}
@@ -1,14 +0,0 @@
using System;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Services
{
public interface IUpdateService
{
bool NeedRestart { get; set; }
Task<Version> CheckPrepareUpdateAsync();
void FinalizeUpdate();
}
}

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