diff --git a/DiscordChatExporter.Core/Internal/AssemblyHelper.cs b/DiscordChatExporter.Core/Internal/Extensions.cs similarity index 67% rename from DiscordChatExporter.Core/Internal/AssemblyHelper.cs rename to DiscordChatExporter.Core/Internal/Extensions.cs index 053518f..048f680 100644 --- a/DiscordChatExporter.Core/Internal/AssemblyHelper.cs +++ b/DiscordChatExporter.Core/Internal/Extensions.cs @@ -4,14 +4,13 @@ using System.Resources; namespace DiscordChatExporter.Core.Internal { - internal static class AssemblyHelper + internal static class Extensions { - public static string GetResourceString(string resourcePath) + public static string GetManifestResourceString(this Assembly assembly, string resourceName) { - var assembly = Assembly.GetExecutingAssembly(); - var stream = assembly.GetManifestResourceStream(resourcePath); + var stream = assembly.GetManifestResourceStream(resourceName); if (stream == null) - throw new MissingManifestResourceException($"Could not find resource [{resourcePath}]."); + throw new MissingManifestResourceException($"Could not find resource [{resourceName}]."); using (stream) using (var reader = new StreamReader(stream)) diff --git a/DiscordChatExporter.Core/Models/User.cs b/DiscordChatExporter.Core/Models/User.cs index 25591e9..eec1579 100644 --- a/DiscordChatExporter.Core/Models/User.cs +++ b/DiscordChatExporter.Core/Models/User.cs @@ -1,5 +1,4 @@ -using System.Globalization; -using Tyrrrz.Extensions; +using Tyrrrz.Extensions; namespace DiscordChatExporter.Core.Models { @@ -11,11 +10,11 @@ namespace DiscordChatExporter.Core.Models public string Name { get; } - public string FullyQualifiedName => $"{Name}#{Discriminator:0000}"; + public string FullName => $"{Name}#{Discriminator:0000}"; public string AvatarHash { get; } - public string DefaultAvatarHash => (Discriminator % 5).ToString(CultureInfo.InvariantCulture); + public string DefaultAvatarHash => $"{Discriminator % 5}"; public string AvatarUrl => AvatarHash.IsNotBlank() ? $"https://cdn.discordapp.com/avatars/{Id}/{AvatarHash}.png" @@ -31,7 +30,7 @@ namespace DiscordChatExporter.Core.Models public override string ToString() { - return FullyQualifiedName; + return FullName; } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.Html.cs b/DiscordChatExporter.Core/Services/ExportService.Html.cs new file mode 100644 index 0000000..4913f11 --- /dev/null +++ b/DiscordChatExporter.Core/Services/ExportService.Html.cs @@ -0,0 +1,208 @@ +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DiscordChatExporter.Core.Models; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Core.Services +{ + public partial class ExportService + { + private string FormatMessageContentHtml(Message message) + { + // A lot of these regexes were inspired by or taken from MarkdownSharp + + var content = message.Content; + + // HTML-encode content + content = HtmlEncode(content); + + // Encode multiline codeblocks (```text```) + content = Regex.Replace(content, + @"```+(?:[^`]*?\n)?([^`]+)\n?```+", + m => $"\x1AM{Base64Encode(m.Groups[1].Value)}\x1AM"); + + // Encode inline codeblocks (`text`) + content = Regex.Replace(content, + @"`([^`]+)`", + m => $"\x1AI{Base64Encode(m.Groups[1].Value)}\x1AI"); + + // Encode URLs + content = Regex.Replace(content, + @"((https?|ftp)://[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\]\(\);]*[-a-zA-Z0-9+&@#/%=~_|\[\])])(?=$|\W)", + m => $"\x1AL{Base64Encode(m.Groups[1].Value)}\x1AL"); + + // Process bold (**text**) + content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "$2"); + + // Process underline (__text__) + content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "$2"); + + // Process italic (*text* or _text_) + content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "$2"); + + // Process strike through (~~text~~) + content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "$2"); + + // Decode and process multiline codeblocks + content = Regex.Replace(content, "\x1AM(.*?)\x1AM", + m => $"
{Base64Decode(m.Groups[1].Value)}
"); + + // Decode and process inline codeblocks + content = Regex.Replace(content, "\x1AI(.*?)\x1AI", + m => $"{Base64Decode(m.Groups[1].Value)}"); + + // Decode and process URLs + content = Regex.Replace(content, "\x1AL(.*?)\x1AL", + m => $"{Base64Decode(m.Groups[1].Value)}"); + + // New lines + content = content.Replace("\n", "
"); + + // Meta mentions (@everyone) + content = content.Replace("@everyone", "@everyone"); + + // Meta mentions (@here) + content = content.Replace("@here", "@here"); + + // User mentions (<@id> and <@!id>) + foreach (var mentionedUser in message.MentionedUsers) + { + content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", + $"" + + $"@{HtmlEncode(mentionedUser.Name)}" + + ""); + } + + // Role mentions (<@&id>) + foreach (var mentionedRole in message.MentionedRoles) + { + content = content.Replace($"<@&{mentionedRole.Id}>", + "" + + $"@{HtmlEncode(mentionedRole.Name)}" + + ""); + } + + // Channel mentions (<#id>) + foreach (var mentionedChannel in message.MentionedChannels) + { + content = content.Replace($"<#{mentionedChannel.Id}>", + "" + + $"#{HtmlEncode(mentionedChannel.Name)}" + + ""); + } + + // Custom emojis (<:name:id>) + content = Regex.Replace(content, "<(:.*?:)(\\d*)>", + ""); + + return content; + } + + private async Task ExportAsHtmlAsync(ChannelChatLog log, TextWriter output, string css) + { + // Generation info + await output.WriteLineAsync(""); + + // Html start + await output.WriteLineAsync(""); + await output.WriteLineAsync(""); + + // HEAD + await output.WriteLineAsync(""); + await output.WriteLineAsync($"{log.Guild.Name} - {log.Channel.Name}"); + await output.WriteLineAsync(""); + await output.WriteLineAsync(""); + await output.WriteLineAsync($""); + await output.WriteLineAsync(""); + + // Body start + await output.WriteLineAsync(""); + + // Guild and channel info + await output.WriteLineAsync("
"); + await output.WriteLineAsync("
"); + await output.WriteLineAsync($""); + await output.WriteLineAsync("
"); // info-left + await output.WriteLineAsync("
"); + await output.WriteLineAsync($"
{log.Guild.Name}
"); + await output.WriteLineAsync($"
{log.Channel.Name}
"); + await output.WriteLineAsync($"
{log.Channel.Topic}
"); + await output.WriteLineAsync( + $"
{log.TotalMessageCount:N0} messages
"); + await output.WriteLineAsync("
"); // info-right + await output.WriteLineAsync("
"); // info + + // Chat log + await output.WriteLineAsync("
"); + foreach (var group in log.MessageGroups) + { + await output.WriteLineAsync("
"); + await output.WriteLineAsync("
"); + await output.WriteLineAsync($""); + await output.WriteLineAsync("
"); + + await output.WriteLineAsync("
"); + await output.WriteAsync( + $""); + await output.WriteAsync(HtmlEncode(group.Author.Name)); + await output.WriteLineAsync(""); + var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat)); + await output.WriteLineAsync($"{timeStampFormatted}"); + + // Messages + foreach (var message in group.Messages) + { + // Content + if (message.Content.IsNotBlank()) + { + await output.WriteLineAsync("
"); + var contentFormatted = FormatMessageContentHtml(message); + await output.WriteAsync(contentFormatted); + + // Edited timestamp + if (message.EditedTimeStamp != null) + { + var editedTimeStampFormatted = + HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat)); + await output.WriteAsync( + $"(edited)"); + } + + await output.WriteLineAsync("
"); // msg-content + } + + // Attachments + foreach (var attachment in message.Attachments) + { + if (attachment.Type == AttachmentType.Image) + { + await output.WriteLineAsync("
"); + await output.WriteLineAsync($""); + await output.WriteLineAsync($""); + await output.WriteLineAsync(""); + await output.WriteLineAsync("
"); + } + else + { + await output.WriteLineAsync(""); + } + } + } + + await output.WriteLineAsync("
"); // msg-right + await output.WriteLineAsync("
"); // msg + } + + await output.WriteLineAsync("
"); // log + + await output.WriteLineAsync(""); + await output.WriteLineAsync(""); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.PlainText.cs b/DiscordChatExporter.Core/Services/ExportService.PlainText.cs new file mode 100644 index 0000000..af0734a --- /dev/null +++ b/DiscordChatExporter.Core/Services/ExportService.PlainText.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DiscordChatExporter.Core.Models; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Core.Services +{ + public partial class ExportService + { + private string FormatMessageContentPlainText(Message message) + { + var content = message.Content; + + // New lines + content = content.Replace("\n", Environment.NewLine); + + // User mentions (<@id> and <@!id>) + foreach (var mentionedUser in message.MentionedUsers) + content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}"); + + // Role mentions (<@&id>) + foreach (var mentionedRole in message.MentionedRoles) + content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}"); + + // Channel mentions (<#id>) + foreach (var mentionedChannel in message.MentionedChannels) + content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}"); + + // Custom emojis (<:name:id>) + content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); + + return content; + } + + private async Task ExportAsPlainTextAsync(ChannelChatLog log, TextWriter output) + { + // Generation info + await output.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter"); + await output.WriteLineAsync(); + + // Guild and channel info + await output.WriteLineAsync('='.Repeat(48)); + await output.WriteLineAsync($"Guild: {log.Guild.Name}"); + await output.WriteLineAsync($"Channel: {log.Channel.Name}"); + await output.WriteLineAsync($"Topic: {log.Channel.Topic}"); + await output.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}"); + await output.WriteLineAsync('='.Repeat(48)); + await output.WriteLineAsync(); + + // Chat log + foreach (var group in log.MessageGroups) + { + var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat); + await output.WriteLineAsync($"{group.Author.FullName} [{timeStampFormatted}]"); + + // Messages + foreach (var message in group.Messages) + { + // Content + if (message.Content.IsNotBlank()) + { + var contentFormatted = FormatMessageContentPlainText(message); + await output.WriteLineAsync(contentFormatted); + } + + // Attachments + foreach (var attachment in message.Attachments) + { + await output.WriteLineAsync(attachment.Url); + } + } + + await output.WriteLineAsync(); + } + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Services/ExportService.cs b/DiscordChatExporter.Core/Services/ExportService.cs index 16ae1d0..da7dba1 100644 --- a/DiscordChatExporter.Core/Services/ExportService.cs +++ b/DiscordChatExporter.Core/Services/ExportService.cs @@ -1,8 +1,7 @@ using System; using System.IO; using System.Net; -using System.Text; -using System.Text.RegularExpressions; +using System.Reflection; using System.Threading.Tasks; using DiscordChatExporter.Core.Internal; using DiscordChatExporter.Core.Models; @@ -19,190 +18,49 @@ namespace DiscordChatExporter.Core.Services _settingsService = settingsService; } - private async Task ExportAsTextAsync(string filePath, ChannelChatLog log) + public async Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log) { - using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024)) + using (var output = File.CreateText(filePath)) { - // Generation info - await writer.WriteLineAsync("https://github.com/Tyrrrz/DiscordChatExporter"); - await writer.WriteLineAsync(); - - // Guild and channel info - await writer.WriteLineAsync('='.Repeat(48)); - await writer.WriteLineAsync($"Guild: {log.Guild.Name}"); - await writer.WriteLineAsync($"Channel: {log.Channel.Name}"); - await writer.WriteLineAsync($"Topic: {log.Channel.Topic}"); - await writer.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}"); - await writer.WriteLineAsync('='.Repeat(48)); - await writer.WriteLineAsync(); - - // Chat log - foreach (var group in log.MessageGroups) + if (format == ExportFormat.PlainText) { - var timeStampFormatted = group.TimeStamp.ToString(_settingsService.DateFormat); - await writer.WriteLineAsync($"{group.Author.FullyQualifiedName} [{timeStampFormatted}]"); - - // Messages - foreach (var message in group.Messages) - { - // Content - if (message.Content.IsNotBlank()) - { - var contentFormatted = FormatMessageContentText(message); - await writer.WriteLineAsync(contentFormatted); - } - - // Attachments - foreach (var attachment in message.Attachments) - { - await writer.WriteLineAsync(attachment.Url); - } - } - - await writer.WriteLineAsync(); + await ExportAsPlainTextAsync(log, output); } - } - } - - private async Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, string css) - { - using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024)) - { - // Generation info - await writer.WriteLineAsync(""); - - // Html start - await writer.WriteLineAsync(""); - await writer.WriteLineAsync(""); - - // HEAD - await writer.WriteLineAsync(""); - await writer.WriteLineAsync($"{log.Guild.Name} - {log.Channel.Name}"); - await writer.WriteLineAsync(""); - await writer.WriteLineAsync(""); - await writer.WriteLineAsync($""); - await writer.WriteLineAsync(""); - - // Body start - await writer.WriteLineAsync(""); - - // Guild and channel info - await writer.WriteLineAsync("
"); - await writer.WriteLineAsync("
"); - await writer.WriteLineAsync($""); - await writer.WriteLineAsync("
"); // info-left - await writer.WriteLineAsync("
"); - await writer.WriteLineAsync($"
{log.Guild.Name}
"); - await writer.WriteLineAsync($"
{log.Channel.Name}
"); - await writer.WriteLineAsync($"
{log.Channel.Topic}
"); - await writer.WriteLineAsync( - $"
{log.TotalMessageCount:N0} messages
"); - await writer.WriteLineAsync("
"); // info-right - await writer.WriteLineAsync("
"); // info - // Chat log - await writer.WriteLineAsync("
"); - foreach (var group in log.MessageGroups) + else if (format == ExportFormat.HtmlDark) { - await writer.WriteLineAsync("
"); - await writer.WriteLineAsync("
"); - await writer.WriteLineAsync($""); - await writer.WriteLineAsync("
"); - - await writer.WriteLineAsync("
"); - await writer.WriteAsync( - $""); - await writer.WriteAsync(HtmlEncode(group.Author.Name)); - await writer.WriteLineAsync(""); - var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(_settingsService.DateFormat)); - await writer.WriteLineAsync($"{timeStampFormatted}"); - - // Messages - foreach (var message in group.Messages) - { - // Content - if (message.Content.IsNotBlank()) - { - await writer.WriteLineAsync("
"); - var contentFormatted = FormatMessageContentHtml(message); - await writer.WriteAsync(contentFormatted); - - // Edited timestamp - if (message.EditedTimeStamp != null) - { - var editedTimeStampFormatted = - HtmlEncode(message.EditedTimeStamp.Value.ToString(_settingsService.DateFormat)); - await writer.WriteAsync( - $"(edited)"); - } - - await writer.WriteLineAsync("
"); // msg-content - } - - // Attachments - foreach (var attachment in message.Attachments) - { - if (attachment.Type == AttachmentType.Image) - { - await writer.WriteLineAsync("
"); - await writer.WriteLineAsync($""); - await writer.WriteLineAsync( - $""); - await writer.WriteLineAsync(""); - await writer.WriteLineAsync("
"); - } - else - { - await writer.WriteLineAsync(""); - } - } - } - await writer.WriteLineAsync("
"); // msg-right - await writer.WriteLineAsync("
"); // msg + var css = Assembly.GetExecutingAssembly() + .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css"); + await ExportAsHtmlAsync(log, output, css); } - await writer.WriteLineAsync("
"); // log - await writer.WriteLineAsync(""); - await writer.WriteLineAsync(""); - } - } + else if (format == ExportFormat.HtmlLight) + { + var css = Assembly.GetExecutingAssembly() + .GetManifestResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css"); + await ExportAsHtmlAsync(log, output, css); + } - public Task ExportAsync(ExportFormat format, string filePath, ChannelChatLog log) - { - if (format == ExportFormat.PlainText) - { - return ExportAsTextAsync(filePath, log); - } - if (format == ExportFormat.HtmlDark) - { - var css = AssemblyHelper.GetResourceString("DiscordChatExporter.Core.Resources.ExportService.DarkTheme.css"); - return ExportAsHtmlAsync(filePath, log, css); + else throw new ArgumentOutOfRangeException(nameof(format)); } - if (format == ExportFormat.HtmlLight) - { - var css = AssemblyHelper.GetResourceString("DiscordChatExporter.Core.Resources.ExportService.LightTheme.css"); - return ExportAsHtmlAsync(filePath, log, css); - } - - throw new ArgumentOutOfRangeException(nameof(format)); } } public partial class ExportService { - private static string HtmlEncode(string str) + private static string Base64Encode(string str) { - return WebUtility.HtmlEncode(str); + return str.GetBytes().ToBase64(); } - private static string HtmlEncode(object obj) + private static string Base64Decode(string str) + { + return str.FromBase64().GetString(); + } + + private static string HtmlEncode(string str) { - return WebUtility.HtmlEncode(obj.ToString()); + return WebUtility.HtmlEncode(str); } private static string FormatFileSize(long fileSize) @@ -219,104 +77,5 @@ namespace DiscordChatExporter.Core.Services return $"{size:0.#} {units[unit]}"; } - - public static string FormatMessageContentText(Message message) - { - var content = message.Content; - - // New lines - content = content.Replace("\n", Environment.NewLine); - - // User mentions (<@id> and <@!id>) - foreach (var mentionedUser in message.MentionedUsers) - content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", $"@{mentionedUser}"); - - // Role mentions (<@&id>) - foreach (var mentionedRole in message.MentionedRoles) - content = content.Replace($"<@&{mentionedRole.Id}>", $"@{mentionedRole.Name}"); - - // Channel mentions (<#id>) - foreach (var mentionedChannel in message.MentionedChannels) - content = content.Replace($"<#{mentionedChannel.Id}>", $"#{mentionedChannel.Name}"); - - // Custom emojis (<:name:id>) - content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); - - return content; - } - - private static string FormatMessageContentHtml(Message message) - { - var content = message.Content; - - // Encode HTML - content = HtmlEncode(content); - - // Pre multiline (```text```) - content = Regex.Replace(content, "```+(?:[^`]*?\\n)?([^`]+)\\n?```+", "
$1
"); - - // Pre inline (`text`) - content = Regex.Replace(content, "`([^`]+)`", "$1"); - - // Bold (**text**) - content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "$1"); - - // Italic (*text*) - content = Regex.Replace(content, "\\*([^\\*]*?)\\*", "$1"); - - // Underline (__text__) - content = Regex.Replace(content, "__([^_]*?)__", "$1"); - - // Italic (_text_) - content = Regex.Replace(content, "_([^_]*?)_", "$1"); - - // Strike through (~~text~~) - content = Regex.Replace(content, "~~([^~]*?)~~", "$1"); - - // New lines - content = content.Replace("\n", "
"); - - // URL links - content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s<>]*)", "$1"); - - // Meta mentions (@everyone) - content = content.Replace("@everyone", "@everyone"); - - // Meta mentions (@here) - content = content.Replace("@here", "@here"); - - // User mentions (<@id> and <@!id>) - foreach (var mentionedUser in message.MentionedUsers) - { - content = Regex.Replace(content, $"<@!?{mentionedUser.Id}>", - $"" + - $"@{HtmlEncode(mentionedUser.Name)}" + - ""); - } - - // Role mentions (<@&id>) - foreach (var mentionedRole in message.MentionedRoles) - { - content = content.Replace($"<@&{mentionedRole.Id}>", - "" + - $"@{HtmlEncode(mentionedRole.Name)}" + - ""); - } - - // Channel mentions (<#id>) - foreach (var mentionedChannel in message.MentionedChannels) - { - content = content.Replace($"<#{mentionedChannel.Id}>", - "" + - $"#{HtmlEncode(mentionedChannel.Name)}" + - ""); - } - - // Custom emojis (<:name:id>) - content = Regex.Replace(content, "<(:.*?:)(\\d*)>", - ""); - - return content; - } } } \ No newline at end of file diff --git a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs index 5f418ce..4d22e98 100644 --- a/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/MainViewModel.cs @@ -235,12 +235,12 @@ namespace DiscordChatExporter.Gui.ViewModels await _exportService.ExportAsync(format, filePath, log); // Notify completion - MessengerInstance.Send(new ShowNotificationMessage($"Export completed for channel [{channel.Name}]", + MessengerInstance.Send(new ShowNotificationMessage("Export complete", "OPEN", () => Process.Start(filePath))); } catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden) { - MessengerInstance.Send(new ShowNotificationMessage("You don't have access to that channel")); + MessengerInstance.Send(new ShowNotificationMessage("You don't have access to this channel")); } IsBusy = false;