Compare commits

..

29 Commits

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

Fixes #179
2019-06-06 23:31:40 +03:00
Oleksii Holub 2ed374ee5c [HTML] Render user ID inside data-user-id attribute
Closes #93
2019-06-06 17:43:30 +03:00
Alexey Golub 0ced31ca4a Update version 2019-05-15 01:04:21 +03:00
Alexey Golub 3bb5638b56 Improve usage instructions slightly 2019-05-15 00:56:50 +03:00
Malcolm Diller 3b5d752c95 Add bot tags and some CSS fixes (#172) 2019-05-09 13:52:46 +03:00
Alexey Golub 2b1f599cbe Add logo image 2019-04-25 22:14:17 +03:00
Alexey Golub 7cb1c21897 Refactor 2019-04-18 22:21:20 +03:00
Alexey Golub 37bcea86e3 [CLI] Update usage guide to explain steps needed to extract DM channel ID 2019-04-18 22:19:26 +03:00
Alexey Golub 559ef71d2f Get rid of Chocolatey 2019-04-17 00:59:22 +03:00
Alexey Golub ab0ce478db Move UpdateService to GUI project 2019-04-16 23:08:38 +03:00
Alexey Golub afb9f52221 Update FodyWeavers.xsd 2019-04-16 23:08:22 +03:00
Alexey Golub d65ca409b1 Update nuget packages 2019-04-13 02:42:03 +03:00
Oleksii Holub 7784383ea7 Fix grammar mistakes in comments 2019-04-12 17:13:12 +03:00
Oleksii Holub 26322da800 [GUI] Notify when all exports are done instead of each of them separately 2019-04-12 16:24:17 +03:00
Oleksii Holub 1325edb135 Use latest C# version in all projects 2019-04-12 16:10:28 +03:00
Oleksii Holub dec2c26d83 Update appveyor image to vs2019 2019-04-12 16:01:01 +03:00
Oleksii Holub 82b0b8cb0a [GUI] Block UI when an export is underway 2019-04-12 15:23:07 +03:00
Oleksii Holub a7da90943f Remove trailing whitespace 2019-04-12 13:48:13 +03:00
Alexey Golub 1edeaf9d3a Update changelog 2019-04-12 01:03:42 +03:00
Alexey Golub 9c043376f9 Update version 2019-04-12 00:14:54 +03:00
Alexey Golub 77366cc9b4 Escape specific emojis in markdown
Fixes a part of #146
2019-04-12 00:11:01 +03:00
Alexey Golub ffdca7b799 [HTML] Fix multiple emojis not getting jumbo 2019-04-11 23:31:29 +03:00
Alexey Golub 30cba7959f Refactor string checks and fix exception when exporting multiple channels
Fix #164
2019-04-11 22:56:29 +03:00
52 changed files with 376 additions and 200 deletions
+25
View File
@@ -1,3 +1,28 @@
### v2.14 (15-Jun-2019)
- [TXT] Added support for embeds.
- [TXT] Added support for reactions.
- [CSV] Added support for reactions.
- [TXT] Changed how attachments are rendered.
### v2.13.1 (06-Jun-2019)
- Fixed an issue where the app sometimes crashed when exporting due to `System.InvalidCastException`.
- [HTML] Added `data-user-id` attribute to `span.chatlog__author-name`. The value of this attribute is author's Discord user ID.
### v2.13 (15-May-2019)
- Updated usage instructions.
- [HTML] Added BOT tags to messages posted by bots.
- [GUI] UI will now be disabled when exporting.
- [GUI] Notification informing that the export has completed will now appear after all exports have completed, instead of each of them separately.
### v2.12.1 (12-Apr-2019)
- [GUI] Fixed an issue where the app crashed when trying to export multiple channels.
- [HTML] Fixed an issue where some of the emojis were rendered via Twemoji while Discord renders them as plain text.
- [HTML] Fixed an issue where emojis weren't jumbo if there were more than one of them.
### v2.12 (11-Apr-2019) ### v2.12 (11-Apr-2019)
- Improved markdown parsing performance which speeds up the final stage of exporting by around 4.5 times. - Improved markdown parsing performance which speeds up the final stage of exporting by around 4.5 times.
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>discordchatexporter</id>
<title>DiscordChatExporter</title>
<version>2.12</version>
<owners>Tyrrrz</owners>
<authors>Tyrrrz</authors>
<copyright>Copyright (C) Alexey Golub</copyright>
<summary>Exports Discord chat logs to a file</summary>
<description>DiscordChatExporter can be used to export message history from a Discord channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. The tool also lets you select from/to dates to limit the exported messages. There are options to configure the output, such as date format, color theme, message grouping limit, etc.</description>
<tags>discordchatexporter discord export chat logs archive gui</tags>
<projectSourceUrl>https://github.com/Tyrrrz/DiscordChatExporter</projectSourceUrl>
<packageSourceUrl>https://github.com/Tyrrrz/DiscordChatExporter</packageSourceUrl>
<projectUrl>http://tyrrrz.me/Projects/DiscordChatExporter</projectUrl>
<releaseNotes>https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md</releaseNotes>
<iconUrl>https://raw.githubusercontent.com/Tyrrrz/DiscordChatExporter/master/favicon.png</iconUrl>
<licenseUrl>https://raw.githubusercontent.com/Tyrrrz/DiscordChatExporter/master/License.txt</licenseUrl>
<requireLicenseAcceptance>true</requireLicenseAcceptance>
<bugTrackerUrl>https://github.com/Tyrrrz/DiscordChatExporter/issues</bugTrackerUrl>
</metadata>
<files>
<file src="tools\**" target="tools" />
</files>
</package>
-24
View File
@@ -1,24 +0,0 @@
$ErrorActionPreference = 'Stop'
$packageName = $env:ChocolateyPackageName
$installDirPath = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
# Install package (GUI)
$packageArgs = @{
packageName = $packageName
unzipLocation = $installDirPath
url = 'https://github.com/Tyrrrz/DiscordChatExporter/releases/download/2.12/DiscordChatExporter.zip'
}
Install-ChocolateyZipPackage @packageArgs
# Mark the executable as GUI
New-Item (Join-Path $installDirPath "DiscordChatExporter.exe.gui") -ItemType File -Force
# Install package (CLI)
$packageArgs = @{
packageName = $packageName
unzipLocation = $installDirPath
url = 'https://github.com/Tyrrrz/DiscordChatExporter/releases/download/2.12/DiscordChatExporter.CLI.zip'
}
Install-ChocolateyZipPackage @packageArgs
+6 -12
View File
@@ -1,4 +1,6 @@
# --- PORTABLE / GUI --- New-Item "$PSScriptRoot\Portable\bin" -ItemType Directory -Force
# --- GUI ---
# Get files # Get files
$files = @() $files = @()
@@ -6,10 +8,9 @@ $files += Get-Item -Path "$PSScriptRoot\..\License.txt"
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config" $files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
# Pack into archive # Pack into archive
New-Item "$PSScriptRoot\Portable\GUI\bin" -ItemType Directory -Force $files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.zip" -Force
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\GUI\bin\DiscordChatExporter.zip" -Force
# --- PORTABLE / CLI --- # --- CLI ---
# Get files # Get files
$files = @() $files = @()
@@ -17,11 +18,4 @@ $files += Get-Item -Path "$PSScriptRoot\..\License.txt"
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net46\*" -Include "*.exe", "*.dll", "*.config" $files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net46\*" -Include "*.exe", "*.dll", "*.config"
# Pack into archive # Pack into archive
New-Item "$PSScriptRoot\Portable\CLI\bin" -ItemType Directory -Force $files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.CLI.zip" -Force
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\CLI\bin\DiscordChatExporter.CLI.zip" -Force
# --- CHOCOLATEY ---
# Create package
New-Item "$PSScriptRoot\Choco\bin\" -ItemType Directory -Force
choco pack $PSScriptRoot\Choco\discordchatexporter.nuspec --out $PSScriptRoot\Choco\bin\
@@ -3,17 +3,18 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks> <TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
<Version>2.12</Version> <Version>2.14</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright> <Copyright>Copyright (c) Alexey Golub</Copyright>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.3.0" /> <PackageReference Include="CommandLineParser" Version="2.3.0" />
<PackageReference Include="Stylet" Version="1.1.22" /> <PackageReference Include="Stylet" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+13 -5
View File
@@ -13,10 +13,10 @@ namespace DiscordChatExporter.Cli
Console.WriteLine("# To get user token:"); Console.WriteLine("# To get user token:");
Console.WriteLine(" 1. Open Discord"); Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools"); Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
Console.WriteLine(" 3. Press Ctrl+R to reload"); Console.WriteLine(" 3. Navigate to the Application tab");
Console.WriteLine(" 4. Navigate to the Application tab"); Console.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
Console.WriteLine(" 5. Select \"Local Storage\" > \"https://discordapp.com\" on the left"); Console.WriteLine(" 5. Press Ctrl+R to reload");
Console.WriteLine(" 6. Find \"token\" under key and copy the value"); Console.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("# To get bot token:"); Console.WriteLine("# To get bot token:");
Console.WriteLine(" 1. Go to Discord developer portal"); Console.WriteLine(" 1. Go to Discord developer portal");
@@ -24,12 +24,20 @@ namespace DiscordChatExporter.Cli
Console.WriteLine(" 3. Navigate to the Bot section on the left"); Console.WriteLine(" 3. Navigate to the Bot section on the left");
Console.WriteLine(" 4. Under Token click Copy"); Console.WriteLine(" 4. Under Token click Copy");
Console.WriteLine(); Console.WriteLine();
Console.WriteLine("# To get guild or channel ID:"); Console.WriteLine("# To get guild ID or guild channel ID:");
Console.WriteLine(" 1. Open Discord"); Console.WriteLine(" 1. Open Discord");
Console.WriteLine(" 2. Open Settings"); Console.WriteLine(" 2. Open Settings");
Console.WriteLine(" 3. Go to Appearance section"); Console.WriteLine(" 3. Go to Appearance section");
Console.WriteLine(" 4. Enable Developer Mode"); Console.WriteLine(" 4. Enable Developer Mode");
Console.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID"); 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) public static void Main(string[] args)
@@ -24,7 +24,7 @@ namespace DiscordChatExporter.Cli.Verbs
var exportService = Container.Instance.Get<ExportService>(); var exportService = Container.Instance.Get<ExportService>();
// Configure settings // Configure settings
if (!Options.DateFormat.EmptyIfNull().IsWhiteSpace()) if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat; settingsService.DateFormat = Options.DateFormat;
// Track progress // Track progress
@@ -37,7 +37,7 @@ namespace DiscordChatExporter.Cli.Verbs
// Generate file path if not set or is a directory // Generate file path if not set or is a directory
var filePath = Options.OutputPath; var filePath = Options.OutputPath;
if (filePath.EmptyIfNull().IsWhiteSpace() || ExportHelper.IsDirectoryPath(filePath)) if (filePath.IsNullOrWhiteSpace() || ExportHelper.IsDirectoryPath(filePath))
{ {
// Generate default file name // Generate default file name
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild, var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
@@ -27,7 +27,7 @@ namespace DiscordChatExporter.Cli.Verbs
var exportService = Container.Instance.Get<ExportService>(); var exportService = Container.Instance.Get<ExportService>();
// Configure settings // Configure settings
if (!Options.DateFormat.EmptyIfNull().IsWhiteSpace()) if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat; settingsService.DateFormat = Options.DateFormat;
// Get channels // Get channels
@@ -28,7 +28,7 @@ namespace DiscordChatExporter.Cli.Verbs
var exportService = Container.Instance.Get<ExportService>(); var exportService = Container.Instance.Get<ExportService>();
// Configure settings // Configure settings
if (!Options.DateFormat.EmptyIfNull().IsWhiteSpace()) if (!Options.DateFormat.IsNullOrWhiteSpace())
settingsService.DateFormat = Options.DateFormat; settingsService.DateFormat = Options.DateFormat;
// Get channels // Get channels
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Cli.Verbs
{ {
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions> public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
{ {
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options) public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
: base(options) : base(options)
{ {
} }
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Cli.Verbs
{ {
public class GetGuildsVerb : Verb<GetGuildsOptions> public class GetGuildsVerb : Verb<GetGuildsOptions>
{ {
public GetGuildsVerb(GetGuildsOptions options) public GetGuildsVerb(GetGuildsOptions options)
: base(options) : base(options)
{ {
} }
@@ -2,10 +2,11 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks> <TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,6 +1,6 @@
namespace DiscordChatExporter.Core.Markdown.Internal namespace DiscordChatExporter.Core.Markdown.Internal
{ {
internal partial class ParsedMatch<T> internal class ParsedMatch<T>
{ {
public int StartIndex { get; } public int StartIndex { get; }
@@ -21,7 +21,7 @@ namespace DiscordChatExporter.Core.Markdown
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk // Capture any character until the earliest single asterisk not preceded or followed by an asterisk
// Opening asterisk must not be followed by whitespace // Opening asterisk must not be followed by whitespace
// Closing asterisk must not be preceeded by whitespace // Closing asterisk must not be preceded by whitespace
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline), new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value))); m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value)));
@@ -113,7 +113,7 @@ namespace DiscordChatExporter.Core.Markdown
// Capture <:lul:123456> or <a:lul:123456> // Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions), new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsEmpty())); m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsNullOrWhiteSpace()));
/* Links */ /* Links */
@@ -140,13 +140,19 @@ namespace DiscordChatExporter.Core.Markdown
@"¯\_(ツ)_/¯", @"¯\_(ツ)_/¯",
s => new TextNode(s)); s => new TextNode(s));
// Capture any "symbol/other" character or surrogate pair preceeded by a backslash // 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 // This escapes it from matching for emoji
private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions), new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
m => new TextNode(m.Value, m.Groups[1].Value)); m => new TextNode(m.Value, m.Groups[1].Value));
// Capture any non-whitespace, non latin alphanumeric character preceeded by a backslash // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
// This escapes it from matching for formatting or other tokens // This escapes it from matching for formatting or other tokens
private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>( private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>(
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions), new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
@@ -155,6 +161,13 @@ namespace DiscordChatExporter.Core.Markdown
// Combine all matchers into one // Combine all matchers into one
// Matchers that have similar patterns are ordered from most specific to least specific // Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<Node> AggregateNodeMatcher = new AggregateMatcher<Node>( private static readonly IMatcher<Node> AggregateNodeMatcher = new AggregateMatcher<Node>(
// Escaped text
ShrugTextNodeMatcher,
IgnoredEmojiTextNodeMatcher,
EscapedSymbolTextNodeMatcher,
EscapedCharacterTextNodeMatcher,
// Formatting
ItalicBoldFormattedNodeMatcher, ItalicBoldFormattedNodeMatcher,
ItalicUnderlineFormattedNodeMatcher, ItalicUnderlineFormattedNodeMatcher,
BoldFormattedNodeMatcher, BoldFormattedNodeMatcher,
@@ -163,21 +176,26 @@ namespace DiscordChatExporter.Core.Markdown
ItalicAltFormattedNodeMatcher, ItalicAltFormattedNodeMatcher,
StrikethroughFormattedNodeMatcher, StrikethroughFormattedNodeMatcher,
SpoilerFormattedNodeMatcher, SpoilerFormattedNodeMatcher,
// Code blocks
MultilineCodeBlockNodeMatcher, MultilineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher, InlineCodeBlockNodeMatcher,
// Mentions
EveryoneMentionNodeMatcher, EveryoneMentionNodeMatcher,
HereMentionNodeMatcher, HereMentionNodeMatcher,
UserMentionNodeMatcher, UserMentionNodeMatcher,
ChannelMentionNodeMatcher, ChannelMentionNodeMatcher,
RoleMentionNodeMatcher, RoleMentionNodeMatcher,
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher, // Links
TitledLinkNodeMatcher, TitledLinkNodeMatcher,
AutoLinkNodeMatcher, AutoLinkNodeMatcher,
HiddenLinkNodeMatcher, HiddenLinkNodeMatcher,
ShrugTextNodeMatcher,
EscapedSymbolTextNodeMatcher, // Emoji
EscapedCharacterTextNodeMatcher); StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher);
private static IReadOnlyList<Node> Parse(string input, IMatcher<Node> matcher) => private static IReadOnlyList<Node> Parse(string input, IMatcher<Node> matcher) =>
matcher.MatchAll(input, s => new TextNode(s)).Select(r => r.Value).ToArray(); matcher.MatchAll(input, s => new TextNode(s)).Select(r => r.Value).ToArray();
@@ -1,4 +1,6 @@
namespace DiscordChatExporter.Core.Markdown.Nodes using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Markdown.Nodes
{ {
public class EmojiNode : Node public class EmojiNode : Node
{ {
@@ -8,7 +10,7 @@
public bool IsAnimated { get; } public bool IsAnimated { get; }
public bool IsCustomEmoji => Id != null; public bool IsCustomEmoji => !Id.IsNullOrWhiteSpace();
public EmojiNode(string source, string id, string name, bool isAnimated) public EmojiNode(string source, string id, string name, bool isAnimated)
: base(source) : base(source)
+1 -1
View File
@@ -15,7 +15,7 @@
public string Topic { get; } public string Topic { get; }
public ChannelType Type { get; } public ChannelType Type { get; }
public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type) public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type)
{ {
Id = id; Id = id;
@@ -2,10 +2,11 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks> <TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1 -1
View File
@@ -40,7 +40,7 @@ namespace DiscordChatExporter.Core.Models
public static string GetImageUrl(string id, string name, bool isAnimated) public static string GetImageUrl(string id, string name, bool isAnimated)
{ {
// Custom emoji // Custom emoji
if (id != null) if (!id.IsNullOrWhiteSpace())
{ {
// Animated // Animated
if (isAnimated) if (isAnimated)
+4 -2
View File
@@ -1,4 +1,6 @@
namespace DiscordChatExporter.Core.Models using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models
{ {
// https://discordapp.com/developers/docs/resources/guild#guild-object // https://discordapp.com/developers/docs/resources/guild#guild-object
@@ -28,7 +30,7 @@
{ {
public static string GetIconUrl(string id, string iconHash) public static string GetIconUrl(string id, string iconHash)
{ {
return iconHash != null return !iconHash.IsNullOrWhiteSpace()
? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png" ? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png"; : "https://cdn.discordapp.com/embed/avatars/0.png";
} }
+7 -3
View File
@@ -1,4 +1,5 @@
using System; using System;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Models namespace DiscordChatExporter.Core.Models
{ {
@@ -18,12 +19,15 @@ namespace DiscordChatExporter.Core.Models
public string AvatarUrl { get; } public string AvatarUrl { get; }
public User(string id, int discriminator, string name, string avatarHash) public bool IsBot { get; }
public User(string id, int discriminator, string name, string avatarHash, bool isBot)
{ {
Id = id; Id = id;
Discriminator = discriminator; Discriminator = discriminator;
Name = name; Name = name;
AvatarHash = avatarHash; AvatarHash = avatarHash;
IsBot = isBot;
FullName = GetFullName(name, discriminator); FullName = GetFullName(name, discriminator);
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash); AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
@@ -39,7 +43,7 @@ namespace DiscordChatExporter.Core.Models
public static string GetAvatarUrl(string id, int discriminator, string avatarHash) public static string GetAvatarUrl(string id, int discriminator, string avatarHash)
{ {
// Custom avatar // Custom avatar
if (avatarHash != null) if (!avatarHash.IsNullOrWhiteSpace())
{ {
// Animated // Animated
if (avatarHash.StartsWith("a_", StringComparison.Ordinal)) if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
@@ -53,6 +57,6 @@ namespace DiscordChatExporter.Core.Models
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
} }
public static User CreateUnknownUser(string id) => new User(id, 0, "Unknown", null); public static User CreateUnknownUser(string id) => new User(id, 0, "Unknown", null, false);
} }
} }
@@ -96,6 +96,10 @@ namespace DiscordChatExporter.Core.Rendering
var formattedAttachments = message.Attachments.Select(a => a.Url).JoinToString(","); var formattedAttachments = message.Attachments.Select(a => a.Url).JoinToString(",");
await RenderFieldAsync(writer, formattedAttachments); await RenderFieldAsync(writer, formattedAttachments);
// Reactions
var formattedReactions = message.Reactions.Select(r => r.Emoji.Name + $"({r.Count})").JoinToString(",");
await RenderFieldAsync(writer, formattedReactions);
// Line break // Line break
await writer.WriteLineAsync(); await writer.WriteLineAsync();
} }
@@ -103,7 +107,7 @@ namespace DiscordChatExporter.Core.Rendering
public async Task RenderAsync(TextWriter writer) public async Task RenderAsync(TextWriter writer)
{ {
// Headers // Headers
await writer.WriteLineAsync("Author;Date;Content;Attachments;"); await writer.WriteLineAsync("Author;Date;Content;Attachments;Reactions;");
// Log // Log
foreach (var message in _chatLog.Messages) foreach (var message in _chatLog.Messages)
@@ -2,6 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks> <TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -14,7 +15,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Scriban" Version="2.0.0" /> <PackageReference Include="Scriban" Version="2.0.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,6 +1,6 @@
using DiscordChatExporter.Core.Models; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Rendering namespace DiscordChatExporter.Core.Rendering
{ {
@@ -1,8 +1,8 @@
using Scriban.Parsing; using System.Reflection;
using Scriban.Runtime;
using Scriban;
using System.Reflection;
using System.Threading.Tasks; using System.Threading.Tasks;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering namespace DiscordChatExporter.Core.Rendering
@@ -46,7 +46,7 @@ namespace DiscordChatExporter.Core.Rendering
return true; return true;
}).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g)); }).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
private string FormatMarkdown(Node node, bool isTopLevel, bool isSingle) private string FormatMarkdown(Node node, bool isJumbo)
{ {
// Text node // Text node
if (node is TextNode textNode) if (node is TextNode textNode)
@@ -92,7 +92,7 @@ namespace DiscordChatExporter.Core.Rendering
if (node is MultilineCodeBlockNode multilineCodeBlockNode) if (node is MultilineCodeBlockNode multilineCodeBlockNode)
{ {
// Set language class for syntax highlighting // Set language class for syntax highlighting
var languageCssClass = multilineCodeBlockNode.Language != null var languageCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace()
? "language-" + multilineCodeBlockNode.Language ? "language-" + multilineCodeBlockNode.Language
: null; : null;
@@ -136,8 +136,8 @@ namespace DiscordChatExporter.Core.Rendering
// Get emoji image URL // Get emoji image URL
var emojiImageUrl = Emoji.GetImageUrl(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated); var emojiImageUrl = Emoji.GetImageUrl(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated);
// Emoji can be jumboable if it's the only top-level node // Make emoji large if it's jumbo
var jumboableCssClass = isTopLevel && isSingle ? "emoji--large" : null; var jumboableCssClass = isJumbo ? "emoji--large" : null;
return $"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />"; return $"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />";
} }
@@ -154,8 +154,10 @@ namespace DiscordChatExporter.Core.Rendering
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel) private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
{ {
var isSingle = nodes.Count == 1; // Emojis are jumbo if all top-level nodes are emoji nodes, disregarding whitespace
return nodes.Select(n => FormatMarkdown(n, isTopLevel, isSingle)).JoinToString(""); 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); private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown), true);
@@ -93,6 +93,90 @@ namespace DiscordChatExporter.Core.Rendering
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown)); private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
private async Task RenderAttachmentsAsync(TextWriter writer, IReadOnlyList<Attachment> attachments)
{
if (attachments.Any())
{
await writer.WriteLineAsync("{Attachments}");
foreach (var attachment in attachments)
await writer.WriteLineAsync(attachment.Url);
await writer.WriteLineAsync();
}
}
private async Task RenderEmbedsAsync(TextWriter writer, IReadOnlyList<Embed> embeds)
{
foreach (var embed in embeds)
{
await writer.WriteLineAsync("{Embed}");
// Author name
if (!(embed.Author?.Name).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Author?.Name);
// URL
if (!embed.Url.IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Url);
// Title
if (!embed.Title.IsNullOrWhiteSpace())
await writer.WriteLineAsync(FormatMarkdown(embed.Title));
// Description
if (!embed.Description.IsNullOrWhiteSpace())
await writer.WriteLineAsync(FormatMarkdown(embed.Description));
// Fields
foreach (var field in embed.Fields)
{
// Name
if (!field.Name.IsNullOrWhiteSpace())
await writer.WriteLineAsync(field.Name);
// Value
if (!field.Value.IsNullOrWhiteSpace())
await writer.WriteLineAsync(field.Value);
}
// Thumbnail URL
if (!(embed.Thumbnail?.Url).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Thumbnail?.Url);
// Image URL
if (!(embed.Image?.Url).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Image?.Url);
// Footer text
if (!(embed.Footer?.Text).IsNullOrWhiteSpace())
await writer.WriteLineAsync(embed.Footer?.Text);
await writer.WriteLineAsync();
}
}
private async Task RenderReactionsAsync(TextWriter writer, IReadOnlyList<Reaction> reactions)
{
if (reactions.Any())
{
await writer.WriteLineAsync("{Reactions}");
foreach (var reaction in reactions)
{
await writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1)
await writer.WriteAsync($" ({reaction.Count})");
await writer.WriteAsync(" ");
}
await writer.WriteLineAsync();
await writer.WriteLineAsync();
}
}
private async Task RenderMessageAsync(TextWriter writer, Message message) private async Task RenderMessageAsync(TextWriter writer, Message message)
{ {
// Timestamp and author // Timestamp and author
@@ -101,9 +185,17 @@ namespace DiscordChatExporter.Core.Rendering
// Content // Content
await writer.WriteLineAsync(FormatMarkdown(message.Content)); await writer.WriteLineAsync(FormatMarkdown(message.Content));
// Separator
await writer.WriteLineAsync();
// Attachments // Attachments
foreach (var attachment in message.Attachments) await RenderAttachmentsAsync(writer, message.Attachments);
await writer.WriteLineAsync(attachment.Url);
// Embeds
await RenderEmbedsAsync(writer, message.Embeds);
// Reactions
await RenderReactionsAsync(writer, message.Reactions);
} }
public async Task RenderAsync(TextWriter writer) public async Task RenderAsync(TextWriter writer)
@@ -120,10 +212,7 @@ namespace DiscordChatExporter.Core.Rendering
// Log // Log
foreach (var message in _chatLog.Messages) foreach (var message in _chatLog.Messages)
{
await RenderMessageAsync(writer, message); await RenderMessageAsync(writer, message);
await writer.WriteLineAsync();
}
} }
} }
} }
@@ -1,5 +1,35 @@
/* === GENERAL === */ /* === 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 { body {
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 16px;
@@ -41,6 +71,7 @@ img {
.pre--inline { .pre--inline {
padding: 2px; padding: 2px;
border-radius: 3px; border-radius: 3px;
font-size: 85%;
} }
.mention { .mention {
@@ -289,7 +320,7 @@ img {
} }
.chatlog__embed-footer-text { .chatlog__embed-footer-text {
font-weight: 600; font-weight: 500;
font-size: .75em; font-size: .75em;
} }
@@ -309,4 +340,17 @@ img {
min-width: 9px; min-width: 9px;
margin-left: 6px; margin-left: 6px;
font-size: .875em; 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;
} }
@@ -67,7 +67,10 @@
</div> </div>
<div class="chatlog__messages"> <div class="chatlog__messages">
{{~ # Author name and timestamp ~}} {{~ # 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> <span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}} {{~ # Messages ~}}
@@ -16,8 +16,9 @@ namespace DiscordChatExporter.Core.Services
var discriminator = json["discriminator"].Value<int>(); var discriminator = json["discriminator"].Value<int>();
var name = json["username"].Value<string>(); var name = json["username"].Value<string>();
var avatarHash = json["avatar"].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) private Guild ParseGuild(JToken json)
@@ -41,14 +42,14 @@ namespace DiscordChatExporter.Core.Services
var guildId = json["guild_id"]?.Value<string>(); var guildId = json["guild_id"]?.Value<string>();
// If the guild ID is blank, it's direct messages // If the guild ID is blank, it's direct messages
if (guildId == null) if (guildId.IsNullOrWhiteSpace())
guildId = Guild.DirectMessages.Id; guildId = Guild.DirectMessages.Id;
// Try to extract name // Try to extract name
var name = json["name"]?.Value<string>(); var name = json["name"]?.Value<string>();
// If the name is blank, it's direct messages // If the name is blank, it's direct messages
if (name == null) if (name.IsNullOrWhiteSpace())
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", "); name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
return new Channel(id, parentId, guildId, name, topic, type); return new Channel(id, parentId, guildId, name, topic, type);
@@ -117,7 +118,7 @@ namespace DiscordChatExporter.Core.Services
var title = json["title"]?.Value<string>(); var title = json["title"]?.Value<string>();
var description = json["description"]?.Value<string>(); var description = json["description"]?.Value<string>();
var url = json["url"]?.Value<string>(); var url = json["url"]?.Value<string>();
var timestamp = json["timestamp"]?.Value<DateTimeOffset>(); var timestamp = json["timestamp"]?.Value<DateTime>().ToDateTimeOffset();
// Get color // Get color
var color = json["color"] != null var color = json["color"] != null
@@ -164,8 +165,8 @@ namespace DiscordChatExporter.Core.Services
// Get basic data // Get basic data
var id = json["id"].Value<string>(); var id = json["id"].Value<string>();
var channelId = json["channel_id"].Value<string>(); var channelId = json["channel_id"].Value<string>();
var timestamp = json["timestamp"].Value<DateTimeOffset>(); var timestamp = json["timestamp"].Value<DateTime>().ToDateTimeOffset();
var editedTimestamp = json["edited_timestamp"]?.Value<DateTimeOffset?>(); var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>()?.ToDateTimeOffset();
var content = json["content"].Value<string>(); var content = json["content"].Value<string>();
var type = (MessageType) json["type"].Value<int>(); var type = (MessageType) json["type"].Value<int>();
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
@@ -9,7 +8,6 @@ using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services.Exceptions; using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Internal; using DiscordChatExporter.Core.Services.Internal;
using Failsafe; using Failsafe;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
@@ -48,7 +46,7 @@ namespace DiscordChatExporter.Core.Services
var value = parameter.SubstringAfter("="); var value = parameter.SubstringAfter("=");
// Skip empty values // Skip empty values
if (value.IsEmpty()) if (value.IsNullOrWhiteSpace())
continue; continue;
request.RequestUri = request.RequestUri.SetQueryParameter(key, value); request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
@@ -66,12 +64,7 @@ namespace DiscordChatExporter.Core.Services
var raw = await response.Content.ReadAsStringAsync(); var raw = await response.Content.ReadAsStringAsync();
// Parse // Parse
using (var reader = new JsonTextReader(new StringReader(raw))) return JToken.Parse(raw);
{
reader.DateParseHandling = DateParseHandling.DateTimeOffset;
return JToken.Load(reader);
}
} }
} }
}); });
@@ -2,13 +2,14 @@
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks> <TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Failsafe" Version="1.1.0" /> <PackageReference Include="Failsafe" Version="1.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="Onova" Version="2.4.2" /> <PackageReference Include="Onova" Version="2.4.3" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
</ItemGroup> </ItemGroup>
@@ -38,7 +38,7 @@ namespace DiscordChatExporter.Core.Services
{ {
// Create output directory // Create output directory
var dirPath = Path.GetDirectoryName(filePath); var dirPath = Path.GetDirectoryName(filePath);
if (!dirPath.EmptyIfNull().IsWhiteSpace()) if (!dirPath.IsNullOrWhiteSpace())
Directory.CreateDirectory(dirPath); Directory.CreateDirectory(dirPath);
// Render chat log to output file // Render chat log to output file
@@ -74,7 +74,7 @@ namespace DiscordChatExporter.Core.Services
var partitionFilePath = $"{fileNameWithoutExt} [{partitionNumber} of {partitions.Length}]{fileExt}"; var partitionFilePath = $"{fileNameWithoutExt} [{partitionNumber} of {partitions.Length}]{fileExt}";
// Compose full file path // Compose full file path
if (!dirPath.EmptyIfNull().IsWhiteSpace()) if (!dirPath.IsNullOrWhiteSpace())
partitionFilePath = Path.Combine(dirPath, partitionFilePath); partitionFilePath = Path.Combine(dirPath, partitionFilePath);
// Export // Export
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Services.Helpers namespace DiscordChatExporter.Core.Services.Helpers
{ {
@@ -11,7 +12,7 @@ namespace DiscordChatExporter.Core.Services.Helpers
public static bool IsDirectoryPath(string path) => public static bool IsDirectoryPath(string path) =>
path.Last() == Path.DirectorySeparatorChar || path.Last() == Path.DirectorySeparatorChar ||
path.Last() == Path.AltDirectorySeparatorChar || path.Last() == Path.AltDirectorySeparatorChar ||
Path.GetExtension(path) == null; (Path.GetExtension(path).IsNullOrWhiteSpace() && !File.Exists(path));
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel, public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
DateTimeOffset? after = null, DateTimeOffset? before = null) DateTimeOffset? after = null, DateTimeOffset? before = null)
@@ -5,11 +5,11 @@ namespace DiscordChatExporter.Core.Services.Internal
{ {
internal static class Extensions internal static class Extensions
{ {
public static string ToSnowflake(this DateTimeOffset date) public static DateTimeOffset ToDateTimeOffset(this DateTime dateTime) => new DateTimeOffset(dateTime);
public static string ToSnowflake(this DateTimeOffset dateTime)
{ {
const long epoch = 62135596800000; var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
var unixTime = date.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString(); return value.ToString();
} }
+1 -1
View File
@@ -1,6 +1,6 @@
namespace DiscordChatExporter.Gui namespace DiscordChatExporter.Gui
{ {
public partial class App public partial class App
{ {
} }
} }
@@ -11,18 +11,18 @@ namespace DiscordChatExporter.Gui.Converters
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{ {
if (value is DateTimeOffset date) if (value is DateTimeOffset dateTimeOffsetValue)
return date.DateTime; return dateTimeOffsetValue.DateTime;
return null; return default;
} }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{ {
if (value is DateTime date) if (value is DateTime dateTimeValue)
return new DateTimeOffset(date); return new DateTimeOffset(dateTimeValue);
return null; return default;
} }
} }
} }
@@ -12,10 +12,10 @@ namespace DiscordChatExporter.Gui.Converters
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{ {
if (value is ExportFormat format) if (value is ExportFormat exportFormatValue)
return format.GetDisplayName(); return exportFormatValue.GetDisplayName();
return null; return default;
} }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
@@ -0,0 +1,28 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DiscordChatExporter.Gui.Converters
{
[ValueConversion(typeof(bool), typeof(bool))]
public class InverseBoolConverter : IValueConverter
{
public static InverseBoolConverter Instance { get; } = new InverseBoolConverter();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
return !boolValue;
return default;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
return !boolValue;
return default;
}
}
}
@@ -13,6 +13,7 @@
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids> <ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<WarningLevel>4</WarningLevel> <WarningLevel>4</WarningLevel>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<LangVersion>latest</LangVersion>
<NuGetPackageImportStamp> <NuGetPackageImportStamp>
</NuGetPackageImportStamp> </NuGetPackageImportStamp>
<TargetFrameworkProfile /> <TargetFrameworkProfile />
@@ -61,6 +62,8 @@
<Compile Include="Bootstrapper.cs" /> <Compile Include="Bootstrapper.cs" />
<Compile Include="Converters\DateTimeOffsetToDateTimeConverter.cs" /> <Compile Include="Converters\DateTimeOffsetToDateTimeConverter.cs" />
<Compile Include="Converters\ExportFormatToStringConverter.cs" /> <Compile Include="Converters\ExportFormatToStringConverter.cs" />
<Compile Include="Converters\InverseBoolConverter.cs" />
<Compile Include="Services\UpdateService.cs" />
<Compile Include="ViewModels\Components\ChannelViewModel.cs" /> <Compile Include="ViewModels\Components\ChannelViewModel.cs" />
<Compile Include="ViewModels\Components\GuildViewModel.cs" /> <Compile Include="ViewModels\Components\GuildViewModel.cs" />
<Compile Include="ViewModels\Dialogs\ExportSetupViewModel.cs" /> <Compile Include="ViewModels\Dialogs\ExportSetupViewModel.cs" />
@@ -127,28 +130,28 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress"> <PackageReference Include="Gress">
<Version>1.0.2</Version> <Version>1.1.1</Version>
</PackageReference> </PackageReference>
<PackageReference Include="MaterialDesignColors"> <PackageReference Include="MaterialDesignColors">
<Version>1.1.3</Version> <Version>1.1.3</Version>
</PackageReference> </PackageReference>
<PackageReference Include="MaterialDesignThemes"> <PackageReference Include="MaterialDesignThemes">
<Version>2.5.0.1205</Version> <Version>2.5.1</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Ookii.Dialogs.Wpf"> <PackageReference Include="Ookii.Dialogs.Wpf">
<Version>1.0.0</Version> <Version>1.1.0</Version>
</PackageReference> </PackageReference>
<PackageReference Include="PropertyChanged.Fody"> <PackageReference Include="PropertyChanged.Fody">
<Version>2.6.0</Version> <Version>2.6.1</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Stylet"> <PackageReference Include="Stylet">
<Version>1.1.22</Version> <Version>1.2.0</Version>
</PackageReference> </PackageReference>
<PackageReference Include="System.Windows.Interactivity.WPF"> <PackageReference Include="System.Windows.Interactivity.WPF">
<Version>2.0.20525</Version> <Version>2.0.20525</Version>
</PackageReference> </PackageReference>
<PackageReference Include="Tyrrrz.Extensions"> <PackageReference Include="Tyrrrz.Extensions">
<Version>1.6.0</Version> <Version>1.6.2</Version>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
+8 -3
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuild. --> <!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers"> <xs:element name="Weavers">
<xs:complexType> <xs:complexType>
<xs:all> <xs:all>
@@ -36,12 +36,17 @@
</xs:all> </xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean"> <xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation> <xs:annotation>
<xs:documentation>'true' to run assembly verification on the target assembly after all weavers have been finished.</xs:documentation> <xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation> </xs:annotation>
</xs:attribute> </xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string"> <xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation> <xs:annotation>
<xs:documentation>A comma separated list of error codes that can be safely ignored in assembly verification.</xs:documentation> <xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation> </xs:annotation>
</xs:attribute> </xs:attribute>
</xs:complexType> </xs:complexType>
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")] [assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")] [assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) Alexey Golub")] [assembly: AssemblyCopyright("Copyright (c) Alexey Golub")]
[assembly: AssemblyVersion("2.12")] [assembly: AssemblyVersion("2.14")]
[assembly: AssemblyFileVersion("2.12")] [assembly: AssemblyFileVersion("2.14")]
@@ -1,10 +1,11 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Services;
using Onova; using Onova;
using Onova.Exceptions; using Onova.Exceptions;
using Onova.Services; using Onova.Services;
namespace DiscordChatExporter.Core.Services namespace DiscordChatExporter.Gui.Services
{ {
public class UpdateService : IDisposable public class UpdateService : IDisposable
{ {
@@ -6,6 +6,7 @@ using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Helpers; using DiscordChatExporter.Core.Services.Helpers;
using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{ {
@@ -84,7 +85,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
} }
// If canceled - return // If canceled - return
if (OutputPath == null) if (OutputPath.IsNullOrWhiteSpace())
return; return;
// Close dialog // Close dialog
@@ -6,7 +6,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
{ {
public T DialogResult { get; private set; } public T DialogResult { get; private set; }
public void Close(T dialogResult = default(T)) public void Close(T dialogResult = default)
{ {
// Set the result // Set the result
DialogResult = dialogResult; DialogResult = dialogResult;
@@ -21,6 +21,6 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
} }
public abstract class DialogScreen : DialogScreen<bool?> public abstract class DialogScreen : DialogScreen<bool?>
{ {
} }
} }
@@ -39,6 +39,6 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory, public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
GuildViewModel guild, ChannelViewModel channel) GuildViewModel guild, ChannelViewModel channel)
=> factory.CreateExportSetupViewModel(guild, new[] {channel}); => factory.CreateExportSetupViewModel(guild, new[] { channel });
} }
} }
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services;
using DiscordChatExporter.Core.Services.Exceptions; using DiscordChatExporter.Core.Services.Exceptions;
using DiscordChatExporter.Core.Services.Helpers; using DiscordChatExporter.Core.Services.Helpers;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress; using Gress;
@@ -122,7 +123,7 @@ namespace DiscordChatExporter.Gui.ViewModels
await _dialogManager.ShowDialogAsync(dialog); await _dialogManager.ShowDialogAsync(dialog);
} }
public bool CanPopulateGuildsAndChannels => !IsBusy && !TokenValue.EmptyIfNull().IsWhiteSpace(); public bool CanPopulateGuildsAndChannels => !IsBusy && !TokenValue.IsNullOrWhiteSpace();
public async void PopulateGuildsAndChannels() public async void PopulateGuildsAndChannels()
{ {
@@ -235,7 +236,7 @@ namespace DiscordChatExporter.Gui.ViewModels
} }
} }
public bool CanExportChannels => !IsBusy && SelectedChannels.EmptyIfNull().Any(); public bool CanExportChannels => !IsBusy && !SelectedChannels.IsNullOrEmpty();
public async void ExportChannels() public async void ExportChannels()
{ {
@@ -253,6 +254,7 @@ namespace DiscordChatExporter.Gui.ViewModels
var operations = ProgressManager.CreateOperations(dialog.Channels.Count); var operations = ProgressManager.CreateOperations(dialog.Channels.Count);
// Export channels // Export channels
var successfulExportCount = 0;
for (var i = 0; i < dialog.Channels.Count; i++) for (var i = 0; i < dialog.Channels.Count; i++)
{ {
// Get operation and channel // Get operation and channel
@@ -274,15 +276,15 @@ namespace DiscordChatExporter.Gui.ViewModels
} }
// Get chat log // Get chat log
var chatLog = await _dataService.GetChatLogAsync(token, dialog.Guild, channel, var chatLog = await _dataService.GetChatLogAsync(token, dialog.Guild, channel,
dialog.After, dialog.Before, operation); dialog.After, dialog.Before, operation);
// Export // Export
await _exportService.ExportChatLogAsync(chatLog, filePath, dialog.SelectedFormat, await _exportService.ExportChatLogAsync(chatLog, filePath, dialog.SelectedFormat,
dialog.PartitionLimit); dialog.PartitionLimit);
// Notify completion // Report successful export
Notifications.Enqueue($"Channel [{channel.Model.Name}] successfully exported"); successfulExportCount++;
} }
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden) catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{ {
@@ -298,6 +300,9 @@ namespace DiscordChatExporter.Gui.ViewModels
operation.Dispose(); operation.Dispose();
} }
} }
// Notify of overall completion
Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)");
} }
} }
} }
+9 -8
View File
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors" xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity" xmlns:i="http://schemas.microsoft.com/expression/2010/interactivity"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
@@ -124,7 +125,7 @@
Value="{Binding ProgressManager.Progress, Mode=OneWay}" /> Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
<!-- Content --> <!-- Content -->
<Grid Grid.Row="2"> <Grid Grid.Row="2" IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<!-- Placeholder / usage instructions --> <!-- Placeholder / usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token --> <!-- For user token -->
@@ -137,23 +138,23 @@
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" /> <Run Text="to show developer tools" />
<LineBreak /> <LineBreak />
<Run Text="3. Press" /> <Run Text="3. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run Text="tab" /> <Run Text="tab" />
<LineBreak /> <LineBreak />
<Run Text="5. Select" /> <Run Text="4. Select" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
<Run Text="&gt;" /> <Run Text="&gt;" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" />
<Run Text="on the left" /> <Run Text="on the left" />
<LineBreak /> <LineBreak />
<Run Text="5. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="6. Find" /> <Run Text="6. Find" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
<Run Text="under key and copy the value" /> <Run Text="at the bottom and copy the value" />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> <TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="To authorize using bot token instead, click" /> <Run Text="To authorize using bot token instead, click" />
@@ -4,7 +4,7 @@
{ {
public RootView() public RootView()
{ {
InitializeComponent(); InitializeComponent();
} }
} }
} }
-1
View File
@@ -11,7 +11,6 @@ DiscordChatExporter can be used to export message history from a [Discord](https
## Download ## Download
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases) - [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Chocolatey](https://chocolatey.org/packages/DiscordChatExporter): `choco install discordchatexporter`
- [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter` - [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter) - [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
+1 -3
View File
@@ -9,6 +9,4 @@ function Replace-TextInFile {
} }
Replace-TextInFile "$PSScriptRoot\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" '(?<=<Version>)(.*?)(?=</Version>)' $newVersion Replace-TextInFile "$PSScriptRoot\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" '(?<=<Version>)(.*?)(?=</Version>)' $newVersion
Replace-TextInFile "$PSScriptRoot\DiscordChatExporter.Gui\Properties\AssemblyInfo.cs" '(?<=Assembly.*?Version\(")(.*?)(?="\)\])' $newVersion Replace-TextInFile "$PSScriptRoot\DiscordChatExporter.Gui\Properties\AssemblyInfo.cs" '(?<=Assembly.*?Version\(")(.*?)(?="\)\])' $newVersion
Replace-TextInFile "$PSScriptRoot\Deploy\Choco\discordchatexporter.nuspec" '(?<=<version>)(.*?)(?=</version>)' $newVersion
Replace-TextInFile "$PSScriptRoot\Deploy\Choco\tools\chocolateyinstall.ps1" '(?<=download/)(.*?)(?=/)' $newVersion
+3 -14
View File
@@ -1,6 +1,6 @@
version: '{build}' version: '{build}'
image: Visual Studio 2017 image: Visual Studio 2019 Preview
configuration: Release configuration: Release
before_build: before_build:
@@ -13,12 +13,10 @@ after_build:
- ps: Deploy\Prepare.ps1 - ps: Deploy\Prepare.ps1
artifacts: artifacts:
- path: Deploy\Portable\GUI\bin\DiscordChatExporter.zip - path: Deploy\Portable\bin\DiscordChatExporter.zip
name: DiscordChatExporter.zip name: DiscordChatExporter.zip
- path: Deploy\Portable\CLI\bin\DiscordChatExporter.CLI.zip - path: Deploy\Portable\bin\DiscordChatExporter.CLI.zip
name: DiscordChatExporter.CLI.zip name: DiscordChatExporter.CLI.zip
- path: Deploy\Choco\bin\DiscordChatExporter*.nupkg
name: DiscordChatExporter-Choco.nupkg
deploy: deploy:
- provider: GitHub - provider: GitHub
@@ -26,15 +24,6 @@ deploy:
secure: sjQHWRw29AMiVMn3MtidtWnAzAf1mJ+mkJ/7h1B9TIAHhkFrqwMK7LtXV+uNJ9AO secure: sjQHWRw29AMiVMn3MtidtWnAzAf1mJ+mkJ/7h1B9TIAHhkFrqwMK7LtXV+uNJ9AO
artifact: DiscordChatExporter.zip,DiscordChatExporter.CLI.zip artifact: DiscordChatExporter.zip,DiscordChatExporter.CLI.zip
description: '[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)' description: '[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)'
on:
branch: master
appveyor_repo_tag: true
- provider: NuGet
server: https://push.chocolatey.org/
api_key:
secure: aQVQ50i8GcwxMXTHf2ck5PijnOeVZY8Z9MEROQTvT49/7/az9w8ru8NCyEsV6H5I
artifact: DiscordChatExporter-Choco.nupkg
skip_symbols: true
on: on:
branch: master branch: master
appveyor_repo_tag: true appveyor_repo_tag: true
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB