Compare commits

...

27 Commits

Author SHA1 Message Date
Alexey Golub 5d6edfd937 Update version 2017-10-27 22:36:25 +03:00
Alexey Golub 60a5a69aa4 Improve message date filtering, convert from snowflake to get the 'to' message ID 2017-10-27 22:35:04 +03:00
Alexey Golub cc565598d3 Add support for channel mentions formatting
Closes #19
2017-10-27 22:08:53 +03:00
Alexey Golub 6a7000cbc9 Re-order URL formatting in HTML formatter
Slightly helps #18
2017-10-27 21:34:45 +03:00
Alexey Golub 6706ff95f1 Fix user mentions not resolving on bots 2017-10-27 21:07:54 +03:00
Alexey Golub ecc95c9c7f Make the mentions resolving safer 2017-10-27 21:04:10 +03:00
Alexey Golub d4f2049db6 Fix wrapping issues in html export 2017-10-26 23:36:10 +03:00
Alexey Golub 4d5118de76 Add mention support (#17)
* Support for user mentions and incomplete support for role mentions

* Improve formatting a bit

* Implement a hack to get roles
2017-10-26 22:21:44 +02:00
Alexey Golub 171718989a Add workarounds for non-default message types
Fixes #16
2017-10-26 22:14:50 +03:00
Alexey Golub 75e8d59cc3 Wrap text in inline pre 2017-10-25 23:37:18 +03:00
Alexey Golub 1d7264a71b Add support for custom emojis
Closes #14
2017-10-25 23:31:32 +03:00
Alexey Golub 2c0a63352c Simplify regexes 2017-10-25 22:19:25 +03:00
Alexey Golub 42d4d64695 Add underscore italics to markdown formatter
Fixes #15
2017-10-25 22:04:47 +03:00
Alexey Golub 6968f987ce Small readability improvement 2017-10-16 20:24:34 +03:00
Alexey Golub 5ecbfd50b8 Update deployment script 2017-10-16 13:00:58 +03:00
Alexey Golub e59a1ea8b4 Add deployment script 2017-10-16 12:47:55 +03:00
Alexey Golub 540ce7f3c3 Update readme 2017-10-16 00:37:34 +03:00
Alexey Golub a0d261f966 Add fancy badges 2017-10-15 23:58:33 +03:00
Alexey Golub 10446064d5 Ammy formatting 2017-10-07 22:03:43 +03:00
Alexey Golub 6709821324 Cleanup views 2017-10-06 23:27:17 +03:00
Alexey Golub 705e60c76d Update version 2017-10-02 19:24:12 +03:00
Alexey Golub 8ac8c16db3 Disable whole UI when busy 2017-10-02 19:16:06 +03:00
Alexey Golub 1850caba71 Add 'optional' suffix for from/to date pickers in ExportSetupDialog 2017-10-02 19:12:00 +03:00
Alexey Golub 6ccaf3f106 Fix settings not loading early enough 2017-10-02 19:09:35 +03:00
Alexey Golub d7345e91d3 Improve UX of ExportSetupDialog
Closes #11
2017-10-02 18:59:00 +03:00
Alexey Golub e5e9d4c9ff Tidy up 2017-10-01 12:18:54 +03:00
Alexey Golub b280f8b736 Add AM/PM to default date format as it was previously missing it 2017-10-01 11:27:21 +03:00
32 changed files with 515 additions and 255 deletions
+4 -1
View File
@@ -261,4 +261,7 @@ __pycache__/
*.pyc
# Ammy auto-generated XAML
*.g.xaml
*.g.xaml
# Deploy output
Deploy/Output/
+22
View File
@@ -0,0 +1,22 @@
$path = "$PSScriptRoot\..\DiscordChatExporter\bin\Release\*"
$include = "*.exe", "*.dll", "*.config"
$outputDir = "$PSScriptRoot\Output"
$outputFile = "DiscordChatExporter.zip"
# Create output directory
if (-Not (Test-Path $outputDir))
{
New-Item $outputDir -ItemType Directory
}
# Delete output if already exists
if (Test-Path("$outputDir\$outputFile"))
{
Remove-Item -Path "$outputDir\$outputFile"
}
# Get files
$files = Get-ChildItem -Path $path -Include $include
# Pack into archive
$files | Compress-Archive -DestinationPath "$outputDir\$outputFile"
+5 -5
View File
@@ -11,11 +11,14 @@ namespace DiscordChatExporter
{
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
// Settings
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
// Services
SimpleIoc.Default.Register<IDataService, DataService>();
SimpleIoc.Default.Register<IExportService, ExportService>();
SimpleIoc.Default.Register<IMessageGroupService, MessageGroupService>();
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
// View models
SimpleIoc.Default.Register<IErrorViewModel, ErrorViewModel>(true);
@@ -23,14 +26,11 @@ namespace DiscordChatExporter
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
// Load settings
ServiceLocator.Current.GetInstance<ISettingsService>().Load();
}
public static void Cleanup()
{
// Save settings
// Settings
ServiceLocator.Current.GetInstance<ISettingsService>().Save();
}
@@ -92,6 +92,8 @@
<Compile Include="Models\ChannelType.cs" />
<Compile Include="Models\ExportFormat.cs" />
<Compile Include="Models\Extensions.cs" />
<Compile Include="Models\Role.cs" />
<Compile Include="Models\MessageType.cs" />
<Compile Include="Services\IMessageGroupService.cs" />
<Compile Include="Services\MessageGroupService.cs" />
<Compile Include="ViewModels\ErrorViewModel.cs" />
@@ -148,7 +150,6 @@
<Compile Include="Models\Guild.cs" />
<Compile Include="Models\Message.cs" />
<Compile Include="Models\MessageGroup.cs" />
<Compile Include="Models\Theme.cs" />
<Compile Include="Models\User.cs" />
<Compile Include="Program.cs" />
<Compile Include="Services\DataService.cs" />
+3 -2
View File
@@ -2,7 +2,8 @@
{
public enum ExportFormat
{
Text,
Html
PlainText,
HtmlDark,
HtmlLight
}
}
+21 -4
View File
@@ -1,14 +1,31 @@
namespace DiscordChatExporter.Models
using System;
namespace DiscordChatExporter.Models
{
public static class Extensions
{
public static string GetFileExtension(this ExportFormat format)
{
if (format == ExportFormat.Text)
if (format == ExportFormat.PlainText)
return "txt";
if (format == ExportFormat.Html)
if (format == ExportFormat.HtmlDark)
return "html";
return null;
if (format == ExportFormat.HtmlLight)
return "html";
throw new NotImplementedException();
}
public static string GetDisplayName(this ExportFormat format)
{
if (format == ExportFormat.PlainText)
return "Plain Text";
if (format == ExportFormat.HtmlDark)
return "HTML (Dark)";
if (format == ExportFormat.HtmlLight)
return "HTML (Light)";
throw new NotImplementedException();
}
}
}
+12 -1
View File
@@ -17,9 +17,17 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<Attachment> Attachments { get; }
public IReadOnlyList<User> MentionedUsers { get; }
public IReadOnlyList<Role> MentionedRoles { get; }
public IReadOnlyList<Channel> MentionedChannels { get; }
public Message(string id, User author,
DateTime timeStamp, DateTime? editedTimeStamp,
string content, IReadOnlyList<Attachment> attachments)
string content, IReadOnlyList<Attachment> attachments,
IReadOnlyList<User> mentionedUsers, IReadOnlyList<Role> mentionedRoles,
IReadOnlyList<Channel> mentionedChannels)
{
Id = id;
Author = author;
@@ -27,6 +35,9 @@ namespace DiscordChatExporter.Models
EditedTimeStamp = editedTimeStamp;
Content = content;
Attachments = attachments;
MentionedUsers = mentionedUsers;
MentionedRoles = mentionedRoles;
MentionedChannels = mentionedChannels;
}
public override string ToString()
+14
View File
@@ -0,0 +1,14 @@
namespace DiscordChatExporter.Models
{
public enum MessageType
{
Default,
RecipientAdd,
RecipientRemove,
Call,
ChannelNameChange,
ChannelIconChange,
ChannelPinnedMessage,
GuildMemberJoin
}
}
+20
View File
@@ -0,0 +1,20 @@
namespace DiscordChatExporter.Models
{
public class Role
{
public string Id { get; }
public string Name { get; }
public Role(string id, string name)
{
Id = id;
Name = name;
}
public override string ToString()
{
return Name;
}
}
}
-8
View File
@@ -1,8 +0,0 @@
namespace DiscordChatExporter.Models
{
public enum Theme
{
Dark,
Light
}
}
+17
View File
@@ -1,4 +1,7 @@
using System;
using System.IO;
using System.Reflection;
using System.Resources;
using AmmySidekick;
namespace DiscordChatExporter
@@ -15,5 +18,19 @@ namespace DiscordChatExporter
app.Run();
}
public static string GetResourceString(string resourcePath)
{
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream == null)
throw new MissingManifestResourceException("Could not find resource");
using (stream)
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
}
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")]
[assembly: AssemblyVersion("2.2")]
[assembly: AssemblyFileVersion("2.2")]
[assembly: AssemblyVersion("2.3")]
[assembly: AssemblyFileVersion("2.3")]
@@ -18,7 +18,7 @@ div.pre {
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre;
white-space: pre-wrap;
}
span.pre {
@@ -26,7 +26,7 @@ span.pre {
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre;
white-space: pre-wrap;
}
div#info {
@@ -106,6 +106,7 @@ span.msg-edited {
div.msg-content {
font-size: .9375rem;
padding-top: 5px;
word-break: break-all;
}
div.msg-attachment {
@@ -116,4 +117,14 @@ div.msg-attachment {
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
@@ -18,6 +18,7 @@ div.pre {
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre-wrap;
}
span.pre {
@@ -25,6 +26,7 @@ span.pre {
font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px;
padding-right: 2px;
white-space: pre-wrap;
}
div#info {
@@ -104,6 +106,7 @@ span.msg-edited {
div.msg-content {
font-size: .9375rem;
padding-top: 5px;
word-break: break-all;
}
div.msg-attachment {
@@ -114,4 +117,14 @@ div.msg-attachment {
img.msg-attachment {
max-height: 500px;
max-width: 50%;
}
img.emoji {
height: 24px;
width: 24px;
vertical-align: -.4em;
}
span.mention {
font-weight: 600;
}
+100 -21
View File
@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Exceptions;
using DiscordChatExporter.Models;
@@ -13,7 +14,10 @@ namespace DiscordChatExporter.Services
public partial class DataService : IDataService, IDisposable
{
private const string ApiRoot = "https://discordapp.com/api/v6";
private readonly HttpClient _httpClient = new HttpClient();
private readonly Dictionary<string, Role> _roleCache = new Dictionary<string, Role>();
private readonly Dictionary<string, Channel> _channelCache = new Dictionary<string, Channel>();
private async Task<string> GetStringAsync(string url)
{
@@ -29,6 +33,20 @@ namespace DiscordChatExporter.Services
}
}
private async Task<IReadOnlyList<Role>> GetGuildRolesAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}?token={token}";
// Get response
var content = await GetStringAsync(url);
// Parse
var roles = JToken.Parse(content)["roles"].Select(ParseRole).ToArray();
return roles;
}
public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
{
// Form request url
@@ -40,6 +58,14 @@ namespace DiscordChatExporter.Services
// Parse
var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
// HACK: also get roles for all of them
foreach (var guild in guilds)
{
var roles = await GetGuildRolesAsync(token, guild.Id);
foreach (var role in roles)
_roleCache[role.Id] = role;
}
return guilds;
}
@@ -68,16 +94,21 @@ namespace DiscordChatExporter.Services
// Parse
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
// Cache
foreach (var channel in channels)
_channelCache[channel.Id] = channel;
return channels;
}
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId, DateTime? from, DateTime? to)
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId,
DateTime? from, DateTime? to)
{
var result = new List<Message>();
// We are going backwards from last message to first
// collecting everything between them in batches
string beforeId = null;
var beforeId = to != null ? DateTimeToSnowflake(to.Value) : null;
while (true)
{
// Form request url
@@ -89,21 +120,27 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url);
// Parse
var messages = JArray.Parse(content).Select(ParseMessage);
var messages = JArray.Parse(content).Select(j => ParseMessage(j, _roleCache, _channelCache));
// Add messages to list
var currentMessageId = default(string);
string currentMessageId = null;
foreach (var message in messages)
{
// Break when the message is older than from date
if (from != null && message.TimeStamp < from)
{
currentMessageId = null;
break;
}
// Add message
result.Add(message);
currentMessageId = message.Id;
}
// If no messages - break
if (currentMessageId == null) break;
// If last message is older than from date - break
if (from != null && result.Last().TimeStamp < from) break;
if (currentMessageId == null)
break;
// Otherwise offset the next request
beforeId = currentMessageId;
@@ -112,12 +149,6 @@ namespace DiscordChatExporter.Services
// Messages appear newest first, we need to reverse
result.Reverse();
// Filter
if (from != null)
result.RemoveAll(m => m.TimeStamp < from);
if (to != null)
result.RemoveAll(m => m.TimeStamp > to);
return result;
}
@@ -138,6 +169,23 @@ namespace DiscordChatExporter.Services
public partial class DataService
{
private static string DateTimeToSnowflake(DateTime dateTime)
{
const long epoch = 62135596800000;
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
var value = ((ulong) unixTime - 1420070400000UL) << 22;
return value.ToString();
}
private static Guild ParseGuild(JToken token)
{
var id = token.Value<string>("id");
var name = token.Value<string>("name");
var iconHash = token.Value<string>("icon");
return new Guild(id, name, iconHash);
}
private static User ParseUser(JToken token)
{
var id = token.Value<string>("id");
@@ -148,13 +196,12 @@ namespace DiscordChatExporter.Services
return new User(id, discriminator, name, avatarHash);
}
private static Guild ParseGuild(JToken token)
private static Role ParseRole(JToken token)
{
var id = token.Value<string>("id");
var name = token.Value<string>("name");
var iconHash = token.Value<string>("icon");
return new Guild(id, name, iconHash);
return new Role(id, name);
}
private static Channel ParseChannel(JToken token)
@@ -178,17 +225,31 @@ namespace DiscordChatExporter.Services
return new Channel(id, name, type);
}
private static Message ParseMessage(JToken token)
private static Message ParseMessage(JToken token,
IDictionary<string, Role> roles, IDictionary<string, Channel> channels)
{
// Get basic data
var id = token.Value<string>("id");
var timeStamp = token.Value<DateTime>("timestamp");
var editedTimeStamp = token.Value<DateTime?>("edited_timestamp");
var content = token.Value<string>("content");
var type = (MessageType) token.Value<int>("type");
// Lazy workaround for calls
if (token["call"] != null)
// Workarounds for non-default types
if (type == MessageType.RecipientAdd)
content = "Added a recipient.";
else if (type == MessageType.RecipientRemove)
content = "Removed a recipient.";
else if (type == MessageType.Call)
content = "Started a call.";
else if (type == MessageType.ChannelNameChange)
content = "Changed the channel name.";
else if (type == MessageType.ChannelIconChange)
content = "Changed the channel icon.";
else if (type == MessageType.ChannelPinnedMessage)
content = "Pinned a message.";
else if (type == MessageType.GuildMemberJoin)
content = "Joined the server.";
// Get author
var author = ParseUser(token["author"]);
@@ -211,7 +272,25 @@ namespace DiscordChatExporter.Services
attachments.Add(attachment);
}
return new Message(id, author, timeStamp, editedTimeStamp, content, attachments);
// Get user mentions
var mentionedUsers = token["mentions"].Select(ParseUser).ToArray();
// Get role mentions
var mentionedRoles = token["mention_roles"]
.Values<string>()
.Select(i => roles.GetOrDefault(i) ?? new Role(i, "deleted-role"))
.ToArray();
// Get channel mentions
var mentionedChanenls = Regex.Matches(content, "<#(\\d+)>")
.Cast<Match>()
.Select(m => m.Groups[1].Value)
.ExceptBlank()
.Select(i => channels.GetOrDefault(i) ?? new Channel(i, "deleted-channel", ChannelType.GuildTextChat))
.ToArray();
return new Message(id, author, timeStamp, editedTimeStamp, content, attachments,
mentionedUsers, mentionedRoles, mentionedChanenls);
}
}
}
+114 -48
View File
@@ -1,8 +1,6 @@
using System;
using System.IO;
using System.Net;
using System.Reflection;
using System.Resources;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@@ -20,32 +18,35 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService;
}
public async Task ExportAsTextAsync(string filePath, ChannelChatLog log)
private async Task ExportAsTextAsync(string filePath, ChannelChatLog log)
{
var dateFormat = _settingsService.DateFormat;
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
{
// Generation info
await writer.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter");
await writer.WriteLineAsync();
// Guild and channel info
await writer.WriteLineAsync("=".Repeat(16));
await writer.WriteLineAsync('='.Repeat(48));
await writer.WriteLineAsync($"Guild: {log.Guild}");
await writer.WriteLineAsync($"Channel: {log.Channel}");
await writer.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
await writer.WriteLineAsync("=".Repeat(16));
await writer.WriteLineAsync('='.Repeat(48));
await writer.WriteLineAsync();
// Chat log
foreach (var group in log.MessageGroups)
{
var timeStampFormatted = group.TimeStamp.ToString(dateFormat);
var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat);
await writer.WriteLineAsync($"{group.Author} [{timeStampFormatted}]");
// Messages
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var contentFormatted = message.Content.Replace("\n", Environment.NewLine);
var contentFormatted = FormatMessageContentText(message);
await writer.WriteLineAsync(contentFormatted);
}
@@ -61,11 +62,8 @@ namespace DiscordChatExporter.Services
}
}
public async Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, Theme theme)
private async Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, string css)
{
var themeCss = GetThemeCss(theme);
var dateFormat = _settingsService.DateFormat;
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
{
// Generation info
@@ -80,7 +78,7 @@ namespace DiscordChatExporter.Services
await writer.WriteLineAsync($"<title>{log.Guild} - {log.Channel}</title>");
await writer.WriteLineAsync("<meta charset=\"utf-8\" />");
await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await writer.WriteLineAsync($"<style>{themeCss}</style>");
await writer.WriteLineAsync($"<style>{css}</style>");
await writer.WriteLineAsync("</head>");
// Body start
@@ -111,7 +109,7 @@ namespace DiscordChatExporter.Services
await writer.WriteAsync($"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author)}\">");
await writer.WriteAsync(HtmlEncode(group.Author.Name));
await writer.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(dateFormat));
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat));
await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
@@ -121,14 +119,14 @@ namespace DiscordChatExporter.Services
if (message.Content.IsNotBlank())
{
await writer.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContentHtml(message.Content);
var contentFormatted = FormatMessageContentHtml(message);
await writer.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(dateFormat));
HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat));
await writer.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
@@ -168,26 +166,30 @@ namespace DiscordChatExporter.Services
await writer.WriteLineAsync("</html>");
}
}
public Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log)
{
if (format == ExportFormat.PlainText)
{
return ExportAsTextAsync(filePath, log);
}
if (format == ExportFormat.HtmlDark)
{
var css = Program.GetResourceString("DiscordChatExporter.Resources.ExportService.DarkTheme.css");
return ExportAsHtmlAsync(filePath, log, css);
}
if (format == ExportFormat.HtmlLight)
{
var css = Program.GetResourceString("DiscordChatExporter.Resources.ExportService.LightTheme.css");
return ExportAsHtmlAsync(filePath, log, css);
}
throw new NotImplementedException();
}
}
public partial class ExportService
{
private static string GetThemeCss(Theme theme)
{
var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css";
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream == null)
throw new MissingManifestResourceException("Could not find style resource");
using (stream)
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
@@ -213,38 +215,102 @@ namespace DiscordChatExporter.Services
return $"{size:0.#} {units[unit]}";
}
private static string FormatMessageContentHtml(string content)
public static string FormatMessageContentText(Message message)
{
var content = message.Content;
// New lines
content = content.Replace("\n", Environment.NewLine);
// User mentions (<@id>)
foreach (var mentionedUser in message.MentionedUsers)
content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}");
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}");
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}");
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
return content;
}
private static string FormatMessageContentHtml(Message message)
{
var content = message.Content;
// Encode HTML
content = HtmlEncode(content);
// Preformatted div
content = Regex.Replace(content, "```+(?:[^`]*?\\n)?([^`]+)\\n?```+",
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
// Pre multiline (```text```)
content = Regex.Replace(content, "```+(?:[^`]*?\\n)?([^`]+)\\n?```+", "<div class=\"pre\">$1</div>");
// Preformatted span
content = Regex.Replace(content, "`([^`]+)`",
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
// Pre inline (`text`)
content = Regex.Replace(content, "`([^`]+)`", "<span class=\"pre\">$1</span>");
// Links from URLs
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
"<a href=\"$1\">$1</a>");
// Bold
// Bold (**text**)
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
// Italic
// Italic (*text*)
content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "<i>$1</i>");
// Underline
// Underline (__text__)
content = Regex.Replace(content, "__([^_]*?)__", "<u>$1</u>");
// Strike through
// Italic (_text_)
content = Regex.Replace(content, "_([^_]*?)_", "<i>$1</i>");
// Strike through (~~text~~)
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
// New lines
content = content.Replace("\n", "<br />");
// URL links
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s<>]*)", "<a href=\"$1\">$1</a>");
// 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>)
foreach (var mentionedUser in message.MentionedUsers)
{
content = Regex.Replace(content, $"&lt;@!?{mentionedUser.Id}&gt;",
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser)}\">" +
$"@{HtmlEncode(mentionedUser.Name)}" +
"</span>");
}
// Role mentions (<@&id>)
foreach (var mentionedRole in message.MentionedRoles)
{
content = content.Replace($"&lt;@&amp;{mentionedRole.Id}&gt;",
"<span class=\"mention\">" +
$"@{HtmlEncode(mentionedRole.Name)}" +
"</span>");
}
// Channel mentions (<#id>)
foreach (var mentionedChannel in message.MentionedChannels)
{
content = content.Replace($"&lt;#{mentionedChannel.Id}&gt;",
"<span class=\"mention\">" +
$"#{HtmlEncode(mentionedChannel.Name)}" +
"</span>");
}
// Custom emojis (<:name:id>)
content = Regex.Replace(content, "&lt;(:.*?:)(\\d*)&gt;",
"<img class=\"emoji\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
return content;
}
}
@@ -5,7 +5,6 @@ namespace DiscordChatExporter.Services
{
public interface IExportService
{
Task ExportAsTextAsync(string filePath, ChannelChatLog log);
Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, Theme theme);
Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log);
}
}
@@ -4,7 +4,6 @@ namespace DiscordChatExporter.Services
{
public interface ISettingsService
{
Theme Theme { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
@@ -15,7 +15,6 @@ namespace DiscordChatExporter.Services
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
{
var groupLimit = _settingsService.MessageGroupLimit;
var result = new List<MessageGroup>();
// Group adjacent messages by timestamp and author
@@ -31,7 +30,7 @@ namespace DiscordChatExporter.Services
message.Author.Id != groupFirst.Author.Id ||
(message.TimeStamp - groupFirst.TimeStamp).TotalHours > 1 ||
message.TimeStamp.Hour != groupFirst.TimeStamp.Hour ||
groupBuffer.Count >= groupLimit
groupBuffer.Count >= _settingsService.MessageGroupLimit
);
// If condition is true - flush buffer
@@ -5,12 +5,11 @@ namespace DiscordChatExporter.Services
{
public class SettingsService : SettingsManager, ISettingsService
{
public Theme Theme { get; set; }
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm";
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.Html;
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
public SettingsService()
{
@@ -39,7 +39,15 @@ namespace DiscordChatExporter.ViewModels
public ExportFormat SelectedFormat
{
get => _format;
set => Set(ref _format, value);
set
{
Set(ref _format, value);
// Replace extension in path
var newExt = value.GetFileExtension();
if (FilePath != null && !FilePath.EndsWith(newExt))
FilePath = FilePath.SubstringUntilLast(".") + "." + newExt;
}
}
public DateTime? From
@@ -82,7 +90,10 @@ namespace DiscordChatExporter.ViewModels
private void Export()
{
// Save format
_settingsService.LastExportFormat = SelectedFormat;
// Start export
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
}
}
@@ -1,12 +1,7 @@
using System.Collections.Generic;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.ViewModels
namespace DiscordChatExporter.ViewModels
{
public interface ISettingsViewModel
{
IReadOnlyList<Theme> AvailableThemes { get; }
Theme Theme { get; set; }
string DateFormat { get; set; }
int MessageGroupLimit { get; set; }
}
+21 -13
View File
@@ -23,10 +23,10 @@ namespace DiscordChatExporter.ViewModels
private readonly Dictionary<Guild, IReadOnlyList<Channel>> _guildChannelsMap;
private bool _isBusy;
private string _token;
private IReadOnlyList<Guild> _availableGuilds;
private Guild _selectedGuild;
private IReadOnlyList<Channel> _availableChannels;
private string _cachedToken;
public bool IsBusy
{
@@ -43,13 +43,13 @@ namespace DiscordChatExporter.ViewModels
public string Token
{
get => _settingsService.LastToken;
get => _token;
set
{
// Remove invalid chars
value = value?.Trim('"');
_settingsService.LastToken = value;
Set(ref _token, value);
PullDataCommand.RaiseCanExecuteChanged();
}
}
@@ -107,12 +107,20 @@ namespace DiscordChatExporter.ViewModels
{
Export(m.Channel, m.FilePath, m.Format, m.From, m.To);
});
// Defaults
_token = _settingsService.LastToken;
}
private async void PullData()
{
IsBusy = true;
_cachedToken = Token;
// Copy token so it doesn't get mutated
var token = Token;
// Save token
_settingsService.LastToken = token;
// Clear existing
_guildChannelsMap.Clear();
@@ -121,17 +129,17 @@ namespace DiscordChatExporter.ViewModels
{
// Get DM channels
{
var channels = await _dataService.GetDirectMessageChannelsAsync(_cachedToken);
var channels = await _dataService.GetDirectMessageChannelsAsync(token);
var guild = new Guild("@me", "Direct Messages", null);
_guildChannelsMap[guild] = channels.ToArray();
}
// Get guild channels
{
var guilds = await _dataService.GetGuildsAsync(_cachedToken);
var guilds = await _dataService.GetGuildsAsync(token);
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id);
var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id);
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
}
}
@@ -171,22 +179,22 @@ namespace DiscordChatExporter.ViewModels
{
IsBusy = true;
// Get last used token
var token = _settingsService.LastToken;
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(_cachedToken, channel.Id, from, to);
var messages = await _dataService.GetChannelMessagesAsync(token, channel.Id, from, to);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
var log = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
// Export
if (format == ExportFormat.Text)
await _exportService.ExportAsTextAsync(filePath, chatLog);
else if (format == ExportFormat.Html)
await _exportService.ExportAsHtmlAsync(filePath, chatLog, _settingsService.Theme);
await _exportService.ExportAsync(format, filePath, log);
// Notify completion
MessengerInstance.Send(new ShowExportDoneMessage(filePath));
@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using DiscordChatExporter.Services;
using GalaSoft.MvvmLight;
using Tyrrrz.Extensions;
@@ -12,14 +8,6 @@ namespace DiscordChatExporter.ViewModels
{
private readonly ISettingsService _settingsService;
public IReadOnlyList<Theme> AvailableThemes { get; }
public Theme Theme
{
get => _settingsService.Theme;
set => _settingsService.Theme = value;
}
public string DateFormat
{
get => _settingsService.DateFormat;
@@ -35,9 +23,6 @@ namespace DiscordChatExporter.ViewModels
public SettingsViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
// Defaults
AvailableThemes = Enum.GetValues(typeof(Theme)).Cast<Theme>().ToArray();
}
}
}
+18 -18
View File
@@ -2,24 +2,24 @@
UserControl "DiscordChatExporter.Views.ErrorDialog" {
DataContext: bind ErrorViewModel from $resource Container
Width: 250
Width: 250
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: bind Message
}
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
Style: resource dyn "MaterialDesignFlatButton"
}
StackPanel {
// Message
TextBlock {
Margin: 16
FontSize: 16
TextWrapping: WrapWithOverflow
Text: bind Message
}
// OK
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "OK"
HorizontalAlignment: Right
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
@@ -19,18 +19,18 @@ UserControl "DiscordChatExporter.Views.ExportDoneDialog" {
// Open
Button "OpenButton" {
Margin: 8
Click: OpenButton_Click
Command: bind OpenCommand
Content: "OPEN IT"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
// Dismiss
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "DISMISS"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
}
@@ -1,13 +1,14 @@
using MaterialDesignThemes.Wpf
using DiscordChatExporter.Models
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
DataContext: bind ExportSetupViewModel from $resource Container
Width: 350
Width: 325
StackPanel {
// File path
TextBox {
Margin: "16 16 16 8"
Margin: [16, 16, 16, 8]
HintAssist.Hint: "Output file"
HintAssist.IsFloating: true
IsReadOnly: true
@@ -15,22 +16,39 @@ UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
set [ UpdateSourceTrigger: PropertyChanged ]
}
// Format
ComboBox {
Margin: [16, 8, 16, 8]
HintAssist.Hint: "Export format"
HintAssist.IsFloating: true
IsReadOnly: true
ItemsSource: bind AvailableFormats
SelectedItem: bind SelectedFormat
ItemTemplate: DataTemplate {
TextBlock {
Text: bind
convert (ExportFormat f) => Extensions.GetDisplayName(f)
}
}
}
// Date range
Grid {
#TwoColumns("*", "*")
DatePicker {
Grid.Column: 0
Margin: "16 16 8 8"
HintAssist.Hint: "From"
#Cell(0, 0)
Margin: [16, 20, 8, 8]
HintAssist.Hint: "From (optional)"
HintAssist.IsFloating: true
SelectedDate: bind From
}
DatePicker {
Grid.Column: 1
Margin: "8 16 16 8"
HintAssist.Hint: "To"
#Cell(0, 1)
Margin: [8, 20, 16, 8]
HintAssist.Hint: "To (optional)"
HintAssist.IsFloating: true
SelectedDate: bind To
}
@@ -40,28 +58,29 @@ UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
@StackPanelHorizontal {
HorizontalAlignment: Right
// Export
Button "ExportButton" {
Click: ExportButton_Click
Command: bind ExportCommand
Content: "EXPORT"
// Browse
Button "BrowseButton" {
Margin: 8
Click: BrowseButton_Click
Content: "BROWSE"
Style: resource dyn "MaterialDesignFlatButton"
}
// Browse
Button "BrowseButton" {
Click: BrowseButton_Click
Content: "BROWSE"
// Export
Button "ExportButton" {
Margin: 8
Click: ExportButton_Click
Command: bind ExportCommand
Content: "EXPORT"
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton"
}
// Cancel
Button {
Margin: 8
Command: DialogHost.CloseDialogCommand
Content: "CANCEL"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
}
@@ -1,10 +1,8 @@
using System.Collections.Generic;
using System.Windows;
using System.Windows;
using DiscordChatExporter.Models;
using DiscordChatExporter.ViewModels;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Views
{
@@ -17,39 +15,30 @@ namespace DiscordChatExporter.Views
InitializeComponent();
}
private string GetFilter()
public void BrowseButton_Click(object sender, RoutedEventArgs args)
{
var filters = new List<string>();
foreach (var format in ViewModel.AvailableFormats)
{
var ext = format.GetFileExtension();
filters.Add($"{format} (*.{ext})|*.{ext}");
}
// Get file extension of the selected format
var ext = ViewModel.SelectedFormat.GetFileExtension();
return filters.JoinToString("|");
// Open dialog
var sfd = new SaveFileDialog
{
FileName = ViewModel.FilePath,
Filter = $"{ext.ToUpperInvariant()} Files|*.{ext}|All Files|*.*",
AddExtension = true,
Title = "Select output file"
};
// Assign new file path if dialog was successful
if (sfd.ShowDialog() == true)
{
ViewModel.FilePath = sfd.FileName;
}
}
public void ExportButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
public void BrowseButton_Click(object sender, RoutedEventArgs args)
{
var sfd = new SaveFileDialog
{
FileName = ViewModel.FilePath,
Filter = GetFilter(),
FilterIndex = ViewModel.AvailableFormats.IndexOf(ViewModel.SelectedFormat) + 1,
AddExtension = true,
Title = "Select output file"
};
if (sfd.ShowDialog() == true)
{
ViewModel.FilePath = sfd.FileName;
ViewModel.SelectedFormat = ViewModel.AvailableFormats[sfd.FilterIndex - 1];
}
}
}
}
+21 -18
View File
@@ -20,6 +20,9 @@ Window "DiscordChatExporter.Views.MainWindow" {
DialogHost {
DockPanel {
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
// Toolbar
Border {
DockPanel.Dock: Top
@@ -30,30 +33,32 @@ Window "DiscordChatExporter.Views.MainWindow" {
#TwoColumns("*", "Auto")
Card {
Grid.Column: 0
Margin: "6 6 0 6"
#Cell(0, 0)
Margin: [6, 6, 0, 6]
Grid {
#TwoColumns("*", "Auto")
// Token
TextBox "TokenTextBox" {
Grid.Column: 0
#Cell(0, 0)
Margin: 6
BorderThickness: 0
HintAssist.Hint: "Token"
KeyDown: TokenTextBox_KeyDown
FontSize: 16
Text: bind Token
set [ UpdateSourceTrigger: PropertyChanged ]
TextFieldAssist.DecorationVisibility: Hidden
TextFieldAssist.TextBoxViewMargin: [0, 0, 2, 0]
}
// Submit
Button {
Grid.Column: 1
Margin: "0 6 6 6"
#Cell(0, 1)
Margin: [0, 6, 6, 6]
Padding: 4
Command: bind PullDataCommand
IsDefault: true
Style: resource dyn "MaterialDesignFlatButton"
PackIcon {
@@ -67,7 +72,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
// Popup menu
PopupBox {
Grid.Column: 1
#Cell(0, 1)
Foreground: resource dyn "PrimaryHueMidForegroundBrush"
PlacementMode: LeftAndAlignTopEdges
@@ -98,8 +103,6 @@ Window "DiscordChatExporter.Views.MainWindow" {
Grid {
DockPanel {
Background: resource dyn "MaterialDesignCardBackground"
IsEnabled: bind IsBusy
convert (bool b) => b ? false : true
Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Visible : Visibility.Hidden
@@ -119,7 +122,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
TransitioningContent {
OpeningEffect: TransitionEffect {
Duration: "0:0:0.3"
Kind: SlideInFromRight
Kind: SlideInFromLeft
}
Border {
@@ -128,9 +131,11 @@ Window "DiscordChatExporter.Views.MainWindow" {
Cursor: CursorType.Hand
Image {
Margin: "12 4 12 4"
Margin: [12, 4, 12, 4]
Width: 48
Height: 48
Source: bind IconUrl
ToolTip: bind Name
OpacityMask: RadialGradientBrush {
GradientStops: [
GradientStop { Color: "#FF000000", Offset: 0 }
@@ -138,8 +143,6 @@ Window "DiscordChatExporter.Views.MainWindow" {
GradientStop { Color: "#00000000", Offset: 1 }
]
}
Source: bind IconUrl
ToolTip: bind Name
}
}
}
@@ -174,12 +177,12 @@ Window "DiscordChatExporter.Views.MainWindow" {
]
PackIcon {
Margin: "16 7 0 6"
Margin: [16, 7, 0, 6]
Kind: PackIconKind.Pound
VerticalAlignment: Center
}
TextBlock {
Margin: "3 8 8 8"
Margin: [3, 8, 8, 8]
FontSize: 14
Text: bind Name
VerticalAlignment: Center
@@ -193,7 +196,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
// Content placeholder
StackPanel {
Margin: "32 32 8 8"
Margin: [32, 32, 8, 8]
Visibility: bind IsDataAvailable
convert (bool b) => b ? Visibility.Hidden : Visibility.Visible
@@ -203,13 +206,13 @@ Window "DiscordChatExporter.Views.MainWindow" {
}
TextBlock {
Margin: "0 8 0 0"
Margin: [0, 8, 0, 0]
FontSize: 16
Text: "To obtain it, follow these steps:"
}
TextBlock {
Margin: "8 0 0 0"
Margin: [8, 0, 0, 0]
FontSize: 14
Run {
+9 -17
View File
@@ -1,7 +1,5 @@
using System.Reflection;
using System.Windows.Input;
using DiscordChatExporter.Messages;
using DiscordChatExporter.ViewModels;
using GalaSoft.MvvmLight.Messaging;
using MaterialDesignThemes.Wpf;
using Tyrrrz.Extensions;
@@ -10,26 +8,20 @@ namespace DiscordChatExporter.Views
{
public partial class MainWindow
{
private IMainViewModel ViewModel => (IMainViewModel) DataContext;
public MainWindow()
{
InitializeComponent();
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Messenger.Default.Register<ShowErrorMessage>(this, m => DialogHost.Show(new ErrorDialog()).Forget());
Messenger.Default.Register<ShowExportDoneMessage>(this, m => DialogHost.Show(new ExportDoneDialog()).Forget());
Messenger.Default.Register<ShowExportSetupMessage>(this, m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this, m => DialogHost.Show(new SettingsDialog()).Forget());
}
public void TokenTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
// Execute command
ViewModel.PullDataCommand.Execute(null);
}
// Dialogs
Messenger.Default.Register<ShowErrorMessage>(this,
m => DialogHost.Show(new ErrorDialog()).Forget());
Messenger.Default.Register<ShowExportDoneMessage>(this,
m => DialogHost.Show(new ExportDoneDialog()).Forget());
Messenger.Default.Register<ShowExportSetupMessage>(this,
m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this,
m => DialogHost.Show(new SettingsDialog()).Forget());
}
}
}
+2 -12
View File
@@ -5,19 +5,9 @@ UserControl "DiscordChatExporter.Views.SettingsDialog" {
Width: 250
StackPanel {
// Theme
ComboBox {
Margin: "16 16 16 8"
HintAssist.Hint: "Theme"
HintAssist.IsFloating: true
IsReadOnly: true
ItemsSource: bind AvailableThemes
SelectedItem: bind Theme
}
// Date format
TextBox {
Margin: "16 8 16 8"
Margin: [16, 16, 16, 8]
HintAssist.Hint: "Date format"
HintAssist.IsFloating: true
Text: bind DateFormat
@@ -25,7 +15,7 @@ UserControl "DiscordChatExporter.Views.SettingsDialog" {
// Group limit
TextBox {
Margin: "16 8 16 8"
Margin: [16, 8, 16, 8]
HintAssist.Hint: "Message group limit"
HintAssist.IsFloating: true
Text: bind MessageGroupLimit
+6 -1
View File
@@ -1,5 +1,9 @@
# DiscordChatExporter
[![Build](https://img.shields.io/appveyor/ci/Tyrrrz/DiscordChatExporter/master.svg)](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, 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.
## Screenshots
@@ -9,7 +13,8 @@ DiscordChatExporter can be used to export message history from a [Discord](https
## Download
- [See releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Stable releases](https://github.com/Tyrrrz/DiscordChatExporter/releases)
- [Continuous integration](https://ci.appveyor.com/project/Tyrrrz/DiscordChatExporter)
## Features