From ac64d9943a6bd6612d2e2d29b23e7f820a584d14 Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Sat, 18 Jul 2020 15:45:09 +0300 Subject: [PATCH] Self-contained export (#321) --- .../Commands/Base/ExportCommandBase.cs | 25 ++- .../Base/ExportMultipleCommandBase.cs | 22 ++- .../Discord/AuthToken.cs | 8 +- .../Discord/DiscordClient.cs | 60 +++---- .../Discord/Models/Attachment.cs | 2 +- .../Discord/Models/Channel.cs | 2 +- .../Discord/Models/Embed.cs | 2 +- .../Discord/Models/EmbedAuthor.cs | 2 +- .../Discord/Models/EmbedField.cs | 2 +- .../Discord/Models/EmbedFooter.cs | 2 +- .../Discord/Models/EmbedImage.cs | 2 +- .../Discord/Models/Emoji.cs | 2 +- .../Discord/Models/Guild.cs | 22 ++- .../Discord/Models/Member.cs | 2 +- .../Discord/Models/Message.cs | 7 +- .../Discord/Models/Reaction.cs | 2 +- .../Discord/Models/Role.cs | 2 +- .../Discord/Models/User.cs | 35 ++-- .../DiscordChatExporter.Domain.csproj | 10 +- .../DiscordChatExporterException.cs | 8 +- .../Exporting/ChannelExporter.cs | 121 +++---------- .../Exporting/ExportContext.cs | 57 +++--- .../Exporting/ExportOptions.cs | 18 -- .../Exporting/ExportRequest.cs | 136 ++++++++++++++ .../Exporting/MediaDownloader.cs | 47 +++++ .../Exporting/MessageExporter.cs | 22 ++- .../Exporting/Writers/CsvMessageWriter.cs | 71 ++++++-- .../HtmlCore.css => Writers/Html/Core.css} | 0 .../HtmlDark.css => Writers/Html/Dark.css} | 0 .../Html/LayoutTemplate.html} | 26 +-- .../HtmlLight.css => Writers/Html/Light.css} | 0 .../{ => Writers/Html}/MessageGroup.cs | 16 +- .../Html/MessageGroupTemplate.html} | 24 +-- .../Exporting/Writers/HtmlMessageWriter.cs | 41 ++--- .../Exporting/Writers/JsonMessageWriter.cs | 91 ++++++---- .../MarkdownVisitors/HtmlMarkdownVisitor.cs | 21 ++- .../PlainTextMarkdownVisitor.cs | 12 +- .../Writers/PlainTextMessageWriter.cs | 168 ++++++++---------- .../{ => Extensions}/ColorExtensions.cs | 2 +- .../{ => Extensions}/DateExtensions.cs | 2 +- .../{ => Extensions}/GenericExtensions.cs | 2 +- .../Extensions/HttpClientExtensions.cs | 28 +++ .../{ => Extensions}/JsonElementExtensions.cs | 2 +- .../Internal/Extensions/StringExtensions.cs | 12 ++ .../Utf8JsonWriterExtensions.cs | 2 +- .../Internal/HttpClientExtensions.cs | 17 -- .../Internal/Singleton.cs | 23 +++ .../Internal/StringExtensions.cs | 21 --- .../{Discord => Internal}/UrlBuilder.cs | 12 +- .../Markdown/MarkdownVisitor.cs | 14 +- .../DiscordChatExporter.Gui.csproj | 2 +- .../Services/SettingsService.cs | 2 + .../Dialogs/ExportSetupViewModel.cs | 22 ++- .../ViewModels/RootViewModel.cs | 16 +- .../Views/Dialogs/ExportSetupView.xaml | 108 +++++++---- .../Views/Dialogs/ExportSetupView.xaml.cs | 11 +- 56 files changed, 810 insertions(+), 578 deletions(-) delete mode 100644 DiscordChatExporter.Domain/Exporting/ExportOptions.cs create mode 100644 DiscordChatExporter.Domain/Exporting/ExportRequest.cs create mode 100644 DiscordChatExporter.Domain/Exporting/MediaDownloader.cs rename DiscordChatExporter.Domain/Exporting/{Resources/HtmlCore.css => Writers/Html/Core.css} (100%) rename DiscordChatExporter.Domain/Exporting/{Resources/HtmlDark.css => Writers/Html/Dark.css} (100%) rename DiscordChatExporter.Domain/Exporting/{Resources/HtmlLayoutTemplate.html => Writers/Html/LayoutTemplate.html} (67%) rename DiscordChatExporter.Domain/Exporting/{Resources/HtmlLight.css => Writers/Html/Light.css} (100%) rename DiscordChatExporter.Domain/Exporting/{ => Writers/Html}/MessageGroup.cs (67%) rename DiscordChatExporter.Domain/Exporting/{Resources/HtmlMessageGroupTemplate.html => Writers/Html/MessageGroupTemplate.html} (92%) rename DiscordChatExporter.Domain/Internal/{ => Extensions}/ColorExtensions.cs (85%) rename DiscordChatExporter.Domain/Internal/{ => Extensions}/DateExtensions.cs (89%) rename DiscordChatExporter.Domain/Internal/{ => Extensions}/GenericExtensions.cs (86%) create mode 100644 DiscordChatExporter.Domain/Internal/Extensions/HttpClientExtensions.cs rename DiscordChatExporter.Domain/Internal/{ => Extensions}/JsonElementExtensions.cs (86%) create mode 100644 DiscordChatExporter.Domain/Internal/Extensions/StringExtensions.cs rename DiscordChatExporter.Domain/Internal/{ => Extensions}/Utf8JsonWriterExtensions.cs (92%) delete mode 100644 DiscordChatExporter.Domain/Internal/HttpClientExtensions.cs create mode 100644 DiscordChatExporter.Domain/Internal/Singleton.cs delete mode 100644 DiscordChatExporter.Domain/Internal/StringExtensions.cs rename DiscordChatExporter.Domain/{Discord => Internal}/UrlBuilder.cs (80%) diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index adb8434..c4309dd 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -11,12 +11,12 @@ namespace DiscordChatExporter.Cli.Commands.Base { public abstract class ExportCommandBase : TokenCommandBase { - [CommandOption("format", 'f', Description = "Output file format.")] - public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark; - [CommandOption("output", 'o', Description = "Output file or directory path.")] public string OutputPath { get; set; } = Directory.GetCurrentDirectory(); + [CommandOption("format", 'f', Description = "Output file format.")] + public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark; + [CommandOption("after", Description = "Limit to messages sent after this date.")] public DateTimeOffset? After { get; set; } @@ -26,6 +26,9 @@ namespace DiscordChatExporter.Cli.Commands.Base [CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")] public int? PartitionLimit { get; set; } + [CommandOption("media", Description = "Download referenced media content.")] + public bool ShouldDownloadMedia { get; set; } + [CommandOption("dateformat", Description = "Date format used in output.")] public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt"; @@ -36,9 +39,19 @@ namespace DiscordChatExporter.Cli.Commands.Base console.Output.Write($"Exporting channel '{channel.Category} / {channel.Name}'... "); var progress = console.CreateProgressTicker(); - await GetChannelExporter().ExportAsync(guild, channel, - OutputPath, ExportFormat, DateFormat, PartitionLimit, - After, Before, progress); + var request = new ExportRequest( + guild, + channel, + OutputPath, + ExportFormat, + After, + Before, + PartitionLimit, + ShouldDownloadMedia, + DateFormat + ); + + await GetChannelExporter().ExportChannelAsync(request, progress); console.Output.WriteLine(); console.Output.WriteLine("Done."); diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs index a830637..0167081 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs @@ -7,6 +7,7 @@ using CliFx.Attributes; using CliFx.Utilities; using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Exceptions; +using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Utilities; using Gress; using Tyrrrz.Extensions; @@ -15,13 +16,12 @@ namespace DiscordChatExporter.Cli.Commands.Base { public abstract class ExportMultipleCommandBase : ExportCommandBase { - [CommandOption("parallel", Description = "Export this number of separate channels in parallel.")] + [CommandOption("parallel", Description = "Export this number of channels in parallel.")] public int ParallelLimit { get; set; } = 1; protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList channels) { - // This uses a separate route from ExportCommandBase because the progress ticker is not thread-safe - // Ugly code ahead. Will need to refactor. + // HACK: this uses a separate route from ExportCommandBase because the progress ticker is not thread-safe console.Output.Write($"Exporting {channels.Count} channels... "); var progress = console.CreateProgressTicker(); @@ -39,9 +39,19 @@ namespace DiscordChatExporter.Cli.Commands.Base { var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId); - await GetChannelExporter().ExportAsync(guild, channel, - OutputPath, ExportFormat, DateFormat, PartitionLimit, - After, Before, operation); + var request = new ExportRequest( + guild, + channel, + OutputPath, + ExportFormat, + After, + Before, + PartitionLimit, + ShouldDownloadMedia, + DateFormat + ); + + await GetChannelExporter().ExportChannelAsync(request, operation); Interlocked.Increment(ref successfulExportCount); } diff --git a/DiscordChatExporter.Domain/Discord/AuthToken.cs b/DiscordChatExporter.Domain/Discord/AuthToken.cs index e9d1ade..22b62dc 100644 --- a/DiscordChatExporter.Domain/Discord/AuthToken.cs +++ b/DiscordChatExporter.Domain/Discord/AuthToken.cs @@ -16,9 +16,11 @@ namespace DiscordChatExporter.Domain.Discord Value = value; } - public AuthenticationHeaderValue GetAuthorizationHeader() => Type == AuthTokenType.User - ? new AuthenticationHeaderValue(Value) - : new AuthenticationHeaderValue("Bot", Value); + public AuthenticationHeaderValue GetAuthorizationHeader() => Type switch + { + AuthTokenType.Bot => new AuthenticationHeaderValue("Bot", Value), + _ => new AuthenticationHeaderValue(Value) + }; public override string ToString() => Value; } diff --git a/DiscordChatExporter.Domain/Discord/DiscordClient.cs b/DiscordChatExporter.Domain/Discord/DiscordClient.cs index a1b6efb..473cbc0 100644 --- a/DiscordChatExporter.Domain/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Domain/Discord/DiscordClient.cs @@ -8,25 +8,26 @@ using System.Threading.Tasks; using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Exceptions; using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; using Polly; namespace DiscordChatExporter.Domain.Discord { - public partial class DiscordClient + public class DiscordClient { private readonly AuthToken _token; - private readonly HttpClient _httpClient; + private readonly HttpClient _httpClient = Singleton.HttpClient; private readonly IAsyncPolicy _httpRequestPolicy; private readonly Uri _baseUri = new Uri("https://discordapp.com/api/v6/", UriKind.Absolute); - public DiscordClient(AuthToken token, HttpClient httpClient) + public DiscordClient(AuthToken token) { _token = token; - _httpClient = httpClient; // Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes). - // For that reason the policy will start respecting their retry-after header only after Nth failed response. + // For that reason the policy will ignore such errors at first, then wait a constant amount of time, and + // finally wait the specified amount of time, based on how many requests have failed in a row. _httpRequestPolicy = Policy .HandleResult(m => m.StatusCode == HttpStatusCode.TooManyRequests) .OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError) @@ -41,24 +42,17 @@ namespace DiscordChatExporter.Domain.Discord return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i); }, - (response, timespan, retryCount, context) => Task.CompletedTask); + (response, timespan, retryCount, context) => Task.CompletedTask + ); } - public DiscordClient(AuthToken token) - : this(token, LazyHttpClient.Value) + private async Task GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () => { - } + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); + request.Headers.Authorization = _token.GetAuthorizationHeader(); - private async Task GetResponseAsync(string url) - { - return await _httpRequestPolicy.ExecuteAsync(async () => - { - using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); - request.Headers.Authorization = _token.GetAuthorizationHeader(); - - return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - }); - } + return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + }); private async Task GetJsonResponseAsync(string url) { @@ -97,15 +91,14 @@ namespace DiscordChatExporter.Domain.Discord var url = new UrlBuilder() .SetPath("users/@me/guilds") .SetQueryParameter("limit", "100") - .SetQueryParameterIfNotNullOrWhiteSpace("after", afterId) + .SetQueryParameter("after", afterId) .Build(); var response = await GetJsonResponseAsync(url); var isEmpty = true; - foreach (var guildJson in response.EnumerateArray()) + foreach (var guild in response.EnumerateArray().Select(Guild.Parse)) { - var guild = Guild.Parse(guildJson); yield return guild; afterId = guild.Id; @@ -206,7 +199,7 @@ namespace DiscordChatExporter.Domain.Discord var url = new UrlBuilder() .SetPath($"channels/{channelId}/messages") .SetQueryParameter("limit", "1") - .SetQueryParameterIfNotNullOrWhiteSpace("before", before?.ToSnowflake()) + .SetQueryParameter("before", before?.ToSnowflake()) .Build(); var response = await GetJsonResponseAsync(url); @@ -219,11 +212,15 @@ namespace DiscordChatExporter.Domain.Discord DateTimeOffset? before = null, IProgress? progress = null) { - // Get the last message in the specified range + // Get the last message in the specified range. + // This snapshots the boundaries, which means that messages posted after the exported started + // will not appear in the output. + // Additionally, it provides the date of the last message, which is used to calculate progress. var lastMessage = await TryGetLastMessageAsync(channelId, before); if (lastMessage == null || lastMessage.Timestamp < after) yield break; + // Keep track of first message in range in order to calculate progress var firstMessage = default(Message); var afterId = after?.ToSnowflake() ?? "0"; @@ -267,19 +264,4 @@ namespace DiscordChatExporter.Domain.Discord } } } - - public partial class DiscordClient - { - private static readonly Lazy LazyHttpClient = new Lazy(() => - { - var handler = new HttpClientHandler(); - - if (handler.SupportsAutomaticDecompression) - handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; - - handler.UseCookies = false; - - return new HttpClient(handler, true); - }); - } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Discord/Models/Attachment.cs b/DiscordChatExporter.Domain/Discord/Models/Attachment.cs index f8f4277..474e193 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Attachment.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Attachment.cs @@ -3,7 +3,7 @@ using System.IO; using System.Linq; using System.Text.Json; using DiscordChatExporter.Domain.Discord.Models.Common; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/Channel.cs b/DiscordChatExporter.Domain/Discord/Models/Channel.cs index b67f8f4..2b0a389 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Channel.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Channel.cs @@ -1,7 +1,7 @@ using System.Linq; using System.Text.Json; using DiscordChatExporter.Domain.Discord.Models.Common; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; using Tyrrrz.Extensions; namespace DiscordChatExporter.Domain.Discord.Models diff --git a/DiscordChatExporter.Domain/Discord/Models/Embed.cs b/DiscordChatExporter.Domain/Discord/Models/Embed.cs index b75c583..bd15761 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Embed.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Embed.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Drawing; using System.Linq; using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs index 6597a95..dffe793 100644 --- a/DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/EmbedField.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedField.cs index a72faf7..ced7742 100644 --- a/DiscordChatExporter.Domain/Discord/Models/EmbedField.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedField.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs index 44022cf..3b8e8dd 100644 --- a/DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs index aa8f5d9..389e635 100644 --- a/DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/Emoji.cs b/DiscordChatExporter.Domain/Discord/Models/Emoji.cs index f73513b..7c3972d 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Emoji.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Emoji.cs @@ -2,7 +2,7 @@ using System.Linq; using System.Text; using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; using Tyrrrz.Extensions; namespace DiscordChatExporter.Domain.Discord.Models diff --git a/DiscordChatExporter.Domain/Discord/Models/Guild.cs b/DiscordChatExporter.Domain/Discord/Models/Guild.cs index ccbebe8..45ce376 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Guild.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Guild.cs @@ -12,12 +12,11 @@ namespace DiscordChatExporter.Domain.Discord.Models public string IconUrl { get; } - public Guild(string id, string name, string? iconHash) + public Guild(string id, string name, string iconUrl) { Id = id; Name = name; - - IconUrl = GetIconUrl(id, iconHash); + IconUrl = iconUrl; } public override string ToString() => Name; @@ -26,12 +25,13 @@ namespace DiscordChatExporter.Domain.Discord.Models public partial class Guild { public static Guild DirectMessages { get; } = - new Guild("@me", "Direct Messages", null); + new Guild("@me", "Direct Messages", GetDefaultIconUrl()); + + private static string GetDefaultIconUrl() => + "https://cdn.discordapp.com/embed/avatars/0.png"; - private static string GetIconUrl(string id, string? iconHash) => - !string.IsNullOrWhiteSpace(iconHash) - ? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png" - : "https://cdn.discordapp.com/embed/avatars/0.png"; + private static string GetIconUrl(string id, string iconHash) => + $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"; public static Guild Parse(JsonElement json) { @@ -39,7 +39,11 @@ namespace DiscordChatExporter.Domain.Discord.Models var name = json.GetProperty("name").GetString(); var iconHash = json.GetProperty("icon").GetString(); - return new Guild(id, name, iconHash); + var iconUrl = !string.IsNullOrWhiteSpace(iconHash) + ? GetIconUrl(id, iconHash) + : GetDefaultIconUrl(); + + return new Guild(id, name, iconUrl); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Discord/Models/Member.cs b/DiscordChatExporter.Domain/Discord/Models/Member.cs index 0b1356e..2487c51 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Member.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Member.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; using DiscordChatExporter.Domain.Discord.Models.Common; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/Message.cs b/DiscordChatExporter.Domain/Discord/Models/Message.cs index 03df1c4..2375607 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Message.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Message.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; using DiscordChatExporter.Domain.Discord.Models.Common; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { @@ -71,10 +71,7 @@ namespace DiscordChatExporter.Domain.Discord.Models MentionedUsers = mentionedUsers; } - public override string ToString() => - Content ?? (Embeds.Any() - ? "" - : ""); + public override string ToString() => Content; } public partial class Message diff --git a/DiscordChatExporter.Domain/Discord/Models/Reaction.cs b/DiscordChatExporter.Domain/Discord/Models/Reaction.cs index 9f31449..127f266 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Reaction.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Reaction.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/Role.cs b/DiscordChatExporter.Domain/Discord/Models/Role.cs index 12c6b44..9fcc8cb 100644 --- a/DiscordChatExporter.Domain/Discord/Models/Role.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Role.cs @@ -1,6 +1,6 @@ using System.Drawing; using System.Text.Json; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { diff --git a/DiscordChatExporter.Domain/Discord/Models/User.cs b/DiscordChatExporter.Domain/Discord/Models/User.cs index 32f2de0..c56b14a 100644 --- a/DiscordChatExporter.Domain/Discord/Models/User.cs +++ b/DiscordChatExporter.Domain/Discord/Models/User.cs @@ -1,7 +1,7 @@ using System; using System.Text.Json; using DiscordChatExporter.Domain.Discord.Models.Common; -using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Discord.Models { @@ -20,14 +20,13 @@ namespace DiscordChatExporter.Domain.Discord.Models public string AvatarUrl { get; } - public User(string id, bool isBot, int discriminator, string name, string? avatarHash) + public User(string id, bool isBot, int discriminator, string name, string avatarUrl) { Id = id; IsBot = isBot; Discriminator = discriminator; Name = name; - - AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash); + AvatarUrl = avatarUrl; } public override string ToString() => FullName; @@ -35,21 +34,17 @@ namespace DiscordChatExporter.Domain.Discord.Models public partial class User { - private static string GetAvatarUrl(string id, int discriminator, string? avatarHash) - { - // Custom avatar - if (!string.IsNullOrWhiteSpace(avatarHash)) - { - // Animated - if (avatarHash.StartsWith("a_", StringComparison.Ordinal)) - return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif"; + private static string GetDefaultAvatarUrl(int discriminator) => + $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; - // Non-animated - return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png"; - } + private static string GetAvatarUrl(string id, string avatarHash) + { + // Animated + if (avatarHash.StartsWith("a_", StringComparison.Ordinal)) + return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif"; - // Default avatar - return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; + // Non-animated + return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png"; } public static User Parse(JsonElement json) @@ -60,7 +55,11 @@ namespace DiscordChatExporter.Domain.Discord.Models var avatarHash = json.GetProperty("avatar").GetString(); var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false; - return new User(id, isBot, discriminator, name, avatarHash); + var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash) + ? GetAvatarUrl(id, avatarHash) + : GetDefaultAvatarUrl(discriminator); + + return new User(id, isBot, discriminator, name, avatarUrl); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj b/DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj index f2669f5..5644bf7 100644 --- a/DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj +++ b/DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + diff --git a/DiscordChatExporter.Domain/Exceptions/DiscordChatExporterException.cs b/DiscordChatExporter.Domain/Exceptions/DiscordChatExporterException.cs index 3dcf382..d1ee082 100644 --- a/DiscordChatExporter.Domain/Exceptions/DiscordChatExporterException.cs +++ b/DiscordChatExporter.Domain/Exceptions/DiscordChatExporterException.cs @@ -1,6 +1,5 @@ using System; using System.Net.Http; -using DiscordChatExporter.Domain.Discord.Models; namespace DiscordChatExporter.Domain.Exceptions { @@ -43,17 +42,14 @@ Failed to perform an HTTP request. internal static DiscordChatExporterException NotFound() { - const string message = "Not found."; + const string message = "Requested resource does not exist."; return new DiscordChatExporterException(message); } - internal static DiscordChatExporterException ChannelEmpty(string channel) + internal static DiscordChatExporterException ChannelIsEmpty(string channel) { var message = $"Channel '{channel}' contains no messages for the specified period."; return new DiscordChatExporterException(message); } - - internal static DiscordChatExporterException ChannelEmpty(Channel channel) => - ChannelEmpty(channel.Name); } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs b/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs index d995ab8..75f501e 100644 --- a/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using DiscordChatExporter.Domain.Discord; using DiscordChatExporter.Domain.Discord.Models; @@ -12,7 +10,7 @@ using DiscordChatExporter.Domain.Utilities; namespace DiscordChatExporter.Domain.Exporting { - public partial class ChannelExporter + public class ChannelExporter { private readonly DiscordClient _discord; @@ -20,49 +18,38 @@ namespace DiscordChatExporter.Domain.Exporting public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {} - public async Task ExportAsync( - Guild guild, - Channel channel, - string outputPath, - ExportFormat format, - string dateFormat, - int? partitionLimit, - DateTimeOffset? after = null, - DateTimeOffset? before = null, - IProgress? progress = null) + public async Task ExportChannelAsync(ExportRequest request, IProgress? progress = null) { - var baseFilePath = GetFilePathFromOutputPath(guild, channel, outputPath, format, after, before); - - // Options - var options = new ExportOptions(baseFilePath, format, partitionLimit); - - // Context + // Build context var contextMembers = new HashSet(IdBasedEqualityComparer.Instance); - var contextChannels = await _discord.GetGuildChannelsAsync(guild.Id); - var contextRoles = await _discord.GetGuildRolesAsync(guild.Id); + var contextChannels = await _discord.GetGuildChannelsAsync(request.Guild.Id); + var contextRoles = await _discord.GetGuildRolesAsync(request.Guild.Id); var context = new ExportContext( - guild, channel, after, before, dateFormat, - contextMembers, contextChannels, contextRoles + request, + contextMembers, + contextChannels, + contextRoles ); - await using var messageExporter = new MessageExporter(options, context); + // Export messages + await using var messageExporter = new MessageExporter(context); var exportedAnything = false; var encounteredUsers = new HashSet(IdBasedEqualityComparer.Instance); - await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress)) + await foreach (var message in _discord.GetMessagesAsync(request.Channel.Id, request.After, request.Before, progress)) { // Resolve members for referenced users foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author)) { - if (encounteredUsers.Add(referencedUser)) - { - var member = - await _discord.TryGetGuildMemberAsync(guild.Id, referencedUser) ?? - Member.CreateForUser(referencedUser); + if (!encounteredUsers.Add(referencedUser)) + continue; - contextMembers.Add(member); - } + var member = + await _discord.TryGetGuildMemberAsync(request.Guild.Id, referencedUser) ?? + Member.CreateForUser(referencedUser); + + contextMembers.Add(member); } // Export message @@ -72,75 +59,7 @@ namespace DiscordChatExporter.Domain.Exporting // Throw if no messages were exported if (!exportedAnything) - throw DiscordChatExporterException.ChannelEmpty(channel); - } - } - - public partial class ChannelExporter - { - public static string GetDefaultExportFileName( - Guild guild, - Channel channel, - ExportFormat format, - DateTimeOffset? after = null, - DateTimeOffset? before = null) - { - var buffer = new StringBuilder(); - - // Guild and channel names - buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]"); - - // Date range - if (after != null || before != null) - { - buffer.Append(" ("); - - // Both 'after' and 'before' are set - if (after != null && before != null) - { - buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}"); - } - // Only 'after' is set - else if (after != null) - { - buffer.Append($"after {after:yyyy-MM-dd}"); - } - // Only 'before' is set - else - { - buffer.Append($"before {before:yyyy-MM-dd}"); - } - - buffer.Append(")"); - } - - // File extension - buffer.Append($".{format.GetFileExtension()}"); - - // Replace invalid chars - foreach (var invalidChar in Path.GetInvalidFileNameChars()) - buffer.Replace(invalidChar, '_'); - - return buffer.ToString(); - } - - private static string GetFilePathFromOutputPath( - Guild guild, - Channel channel, - string outputPath, - ExportFormat format, - DateTimeOffset? after = null, - DateTimeOffset? before = null) - { - // Output is a directory - if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) - { - var fileName = GetDefaultExportFileName(guild, channel, format, after, before); - return Path.Combine(outputPath, fileName); - } - - // Output is a file - return outputPath; + throw DiscordChatExporterException.ChannelIsEmpty(request.Channel.Name); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/ExportContext.cs b/DiscordChatExporter.Domain/Exporting/ExportContext.cs index d59ac52..a23e83b 100644 --- a/DiscordChatExporter.Domain/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Domain/Exporting/ExportContext.cs @@ -1,22 +1,17 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Drawing; +using System.IO; using System.Linq; +using System.Threading.Tasks; using DiscordChatExporter.Domain.Discord.Models; namespace DiscordChatExporter.Domain.Exporting { - public class ExportContext + internal class ExportContext { - public Guild Guild { get; } + private readonly MediaDownloader _mediaDownloader; - public Channel Channel { get; } - - public DateTimeOffset? After { get; } - - public DateTimeOffset? Before { get; } - - public string DateFormat { get; } + public ExportRequest Request { get; } public IReadOnlyCollection Members { get; } @@ -25,46 +20,50 @@ namespace DiscordChatExporter.Domain.Exporting public IReadOnlyCollection Roles { get; } public ExportContext( - Guild guild, - Channel channel, - DateTimeOffset? after, - DateTimeOffset? before, - string dateFormat, + ExportRequest request, IReadOnlyCollection members, IReadOnlyCollection channels, IReadOnlyCollection roles) { - Guild = guild; - Channel = channel; - After = after; - Before = before; - DateFormat = dateFormat; + Request = request; Members = members; Channels = channels; Roles = roles; + + _mediaDownloader = new MediaDownloader(request.OutputMediaDirPath); } - public Member? TryGetMentionedMember(string id) => + public Member? TryGetMember(string id) => Members.FirstOrDefault(m => m.Id == id); - public Channel? TryGetMentionedChannel(string id) => + public Channel? TryGetChannel(string id) => Channels.FirstOrDefault(c => c.Id == id); - public Role? TryGetMentionedRole(string id) => + public Role? TryGetRole(string id) => Roles.FirstOrDefault(r => r.Id == id); - public Member? TryGetUserMember(User user) => Members - .FirstOrDefault(m => m.Id == user.Id); - public Color? TryGetUserColor(User user) { - var member = TryGetUserMember(user); + var member = TryGetMember(user.Id); var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role); return roles? + .Where(r => r.Color != null) .OrderByDescending(r => r.Position) .Select(r => r.Color) - .FirstOrDefault(c => c != null); + .FirstOrDefault(); + } + + // HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter + public async Task ResolveMediaUrlAsync(string url) + { + if (!Request.ShouldDownloadMedia) + return url; + + var filePath = await _mediaDownloader.DownloadAsync(url).ConfigureAwait(false); + + // Return relative path so that the output files can be copied around without breaking + return Path.GetRelativePath(Request.OutputBaseDirPath, filePath); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/ExportOptions.cs b/DiscordChatExporter.Domain/Exporting/ExportOptions.cs deleted file mode 100644 index faeb1cb..0000000 --- a/DiscordChatExporter.Domain/Exporting/ExportOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace DiscordChatExporter.Domain.Exporting -{ - public class ExportOptions - { - public string BaseFilePath { get; } - - public ExportFormat Format { get; } - - public int? PartitionLimit { get; } - - public ExportOptions(string baseFilePath, ExportFormat format, int? partitionLimit) - { - BaseFilePath = baseFilePath; - Format = format; - PartitionLimit = partitionLimit; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/ExportRequest.cs b/DiscordChatExporter.Domain/Exporting/ExportRequest.cs new file mode 100644 index 0000000..3b4f9f6 --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/ExportRequest.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.Text; +using DiscordChatExporter.Domain.Discord.Models; + +namespace DiscordChatExporter.Domain.Exporting +{ + public partial class ExportRequest + { + public Guild Guild { get; } + + public Channel Channel { get; } + + public string OutputPath { get; } + + public string OutputBaseFilePath { get; } + + public string OutputBaseDirPath { get; } + + public string OutputMediaDirPath { get; } + + public ExportFormat Format { get; } + + public DateTimeOffset? After { get; } + + public DateTimeOffset? Before { get; } + + public int? PartitionLimit { get; } + + public bool ShouldDownloadMedia { get; } + + public string DateFormat { get; } + + public ExportRequest( + Guild guild, + Channel channel, + string outputPath, + ExportFormat format, + DateTimeOffset? after, + DateTimeOffset? before, + int? partitionLimit, + bool shouldDownloadMedia, + string dateFormat) + { + Guild = guild; + Channel = channel; + OutputPath = outputPath; + Format = format; + After = after; + Before = before; + PartitionLimit = partitionLimit; + ShouldDownloadMedia = shouldDownloadMedia; + DateFormat = dateFormat; + + OutputBaseFilePath = GetOutputBaseFilePath( + guild, + channel, + outputPath, + format, + after, + before + ); + + OutputBaseDirPath = Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath; + OutputMediaDirPath = $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}"; + } + } + + public partial class ExportRequest + { + private static string GetOutputBaseFilePath( + Guild guild, + Channel channel, + string outputPath, + ExportFormat format, + DateTimeOffset? after = null, + DateTimeOffset? before = null) + { + // Output is a directory + if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) + { + var fileName = GetDefaultOutputFileName(guild, channel, format, after, before); + return Path.Combine(outputPath, fileName); + } + + // Output is a file + return outputPath; + } + + public static string GetDefaultOutputFileName( + Guild guild, + Channel channel, + ExportFormat format, + DateTimeOffset? after = null, + DateTimeOffset? before = null) + { + var buffer = new StringBuilder(); + + // Guild and channel names + buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]"); + + // Date range + if (after != null || before != null) + { + buffer.Append(" ("); + + // Both 'after' and 'before' are set + if (after != null && before != null) + { + buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}"); + } + // Only 'after' is set + else if (after != null) + { + buffer.Append($"after {after:yyyy-MM-dd}"); + } + // Only 'before' is set + else + { + buffer.Append($"before {before:yyyy-MM-dd}"); + } + + buffer.Append(")"); + } + + // File extension + buffer.Append($".{format.GetFileExtension()}"); + + // Replace invalid chars + foreach (var invalidChar in Path.GetInvalidFileNameChars()) + buffer.Replace(invalidChar, '_'); + + return buffer.ToString(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/MediaDownloader.cs b/DiscordChatExporter.Domain/Exporting/MediaDownloader.cs new file mode 100644 index 0000000..44e7cce --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/MediaDownloader.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Internal.Extensions; + +namespace DiscordChatExporter.Domain.Exporting +{ + internal partial class MediaDownloader + { + private readonly HttpClient _httpClient = Singleton.HttpClient; + private readonly string _workingDirPath; + + private readonly Dictionary _mediaPathMap = new Dictionary(); + + public MediaDownloader(string workingDirPath) + { + _workingDirPath = workingDirPath; + } + + // HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter + public async Task DownloadAsync(string url) + { + if (_mediaPathMap.TryGetValue(url, out var cachedFilePath)) + return cachedFilePath; + + Directory.CreateDirectory(_workingDirPath); + + var extension = Path.GetExtension(GetFileNameFromUrl(url)); + var fileName = $"{Guid.NewGuid()}{extension}"; + var filePath = Path.Combine(_workingDirPath, fileName); + + await _httpClient.DownloadAsync(url, filePath).ConfigureAwait(false); + + return _mediaPathMap[url] = filePath; + } + } + + internal partial class MediaDownloader + { + private static string GetFileNameFromUrl(string url) => + Regex.Match(url, @".+/([^?]*)").Groups[1].Value; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/MessageExporter.cs b/DiscordChatExporter.Domain/Exporting/MessageExporter.cs index 0347b8c..1bb6e6d 100644 --- a/DiscordChatExporter.Domain/Exporting/MessageExporter.cs +++ b/DiscordChatExporter.Domain/Exporting/MessageExporter.cs @@ -8,24 +8,22 @@ namespace DiscordChatExporter.Domain.Exporting { internal partial class MessageExporter : IAsyncDisposable { - private readonly ExportOptions _options; private readonly ExportContext _context; - private long _renderedMessageCount; + private long _messageCount; private int _partitionIndex; private MessageWriter? _writer; - public MessageExporter(ExportOptions options, ExportContext context) + public MessageExporter(ExportContext context) { - _options = options; _context = context; } private bool IsPartitionLimitReached() => - _renderedMessageCount > 0 && - _options.PartitionLimit != null && - _options.PartitionLimit != 0 && - _renderedMessageCount % _options.PartitionLimit == 0; + _messageCount > 0 && + _context.Request.PartitionLimit != null && + _context.Request.PartitionLimit != 0 && + _messageCount % _context.Request.PartitionLimit == 0; private async Task ResetWriterAsync() { @@ -50,13 +48,13 @@ namespace DiscordChatExporter.Domain.Exporting if (_writer != null) return _writer; - var filePath = GetPartitionFilePath(_options.BaseFilePath, _partitionIndex); + var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex); - var dirPath = Path.GetDirectoryName(_options.BaseFilePath); + var dirPath = Path.GetDirectoryName(_context.Request.OutputBaseFilePath); if (!string.IsNullOrWhiteSpace(dirPath)) Directory.CreateDirectory(dirPath); - var writer = CreateMessageWriter(filePath, _options.Format, _context); + var writer = CreateMessageWriter(filePath, _context.Request.Format, _context); await writer.WritePreambleAsync(); return _writer = writer; @@ -66,7 +64,7 @@ namespace DiscordChatExporter.Domain.Exporting { var writer = await GetWriterAsync(); await writer.WriteMessageAsync(message); - _renderedMessageCount++; + _messageCount++; } public async ValueTask DisposeAsync() => await ResetWriterAsync(); diff --git a/DiscordChatExporter.Domain/Exporting/Writers/CsvMessageWriter.cs b/DiscordChatExporter.Domain/Exporting/Writers/CsvMessageWriter.cs index d7c0a25..61b8084 100644 --- a/DiscordChatExporter.Domain/Exporting/Writers/CsvMessageWriter.cs +++ b/DiscordChatExporter.Domain/Exporting/Writers/CsvMessageWriter.cs @@ -1,11 +1,10 @@ -using System.IO; -using System.Linq; +using System.Collections.Generic; +using System.IO; using System.Text; using System.Threading.Tasks; using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors; -using DiscordChatExporter.Domain.Internal; -using Tyrrrz.Extensions; +using DiscordChatExporter.Domain.Internal.Extensions; namespace DiscordChatExporter.Domain.Exporting.Writers { @@ -25,19 +24,65 @@ namespace DiscordChatExporter.Domain.Exporting.Writers public override async Task WritePreambleAsync() => await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions"); - public override async Task WriteMessageAsync(Message message) + private async Task WriteAttachmentsAsync(IReadOnlyList attachments) { var buffer = new StringBuilder(); - buffer - .Append(CsvEncode(message.Author.Id)).Append(',') - .Append(CsvEncode(message.Author.FullName)).Append(',') - .Append(CsvEncode(message.Timestamp.ToLocalString(Context.DateFormat))).Append(',') - .Append(CsvEncode(FormatMarkdown(message.Content))).Append(',') - .Append(CsvEncode(message.Attachments.Select(a => a.Url).JoinToString(","))).Append(',') - .Append(CsvEncode(message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(","))); + foreach (var attachment in attachments) + { + buffer + .AppendIfNotEmpty(',') + .Append(await Context.ResolveMediaUrlAsync(attachment.Url)); + } + + await _writer.WriteAsync(CsvEncode(buffer.ToString())); + } + + private async Task WriteReactionsAsync(IReadOnlyList reactions) + { + var buffer = new StringBuilder(); + + foreach (var reaction in reactions) + { + buffer + .AppendIfNotEmpty(',') + .Append(reaction.Emoji.Name) + .Append(' ') + .Append('(') + .Append(reaction.Count) + .Append(')'); + } + + await _writer.WriteAsync(CsvEncode(buffer.ToString())); + } + + public override async Task WriteMessageAsync(Message message) + { + // Author ID + await _writer.WriteAsync(CsvEncode(message.Author.Id)); + await _writer.WriteAsync(','); + + // Author name + await _writer.WriteAsync(CsvEncode(message.Author.FullName)); + await _writer.WriteAsync(','); + + // Message timestamp + await _writer.WriteAsync(CsvEncode(message.Timestamp.ToLocalString(Context.Request.DateFormat))); + await _writer.WriteAsync(','); + + // Message content + await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content))); + await _writer.WriteAsync(','); + + // Attachments + await WriteAttachmentsAsync(message.Attachments); + await _writer.WriteAsync(','); + + // Reactions + await WriteReactionsAsync(message.Reactions); - await _writer.WriteLineAsync(buffer.ToString()); + // Finish row + await _writer.WriteLineAsync(); } public override async ValueTask DisposeAsync() diff --git a/DiscordChatExporter.Domain/Exporting/Resources/HtmlCore.css b/DiscordChatExporter.Domain/Exporting/Writers/Html/Core.css similarity index 100% rename from DiscordChatExporter.Domain/Exporting/Resources/HtmlCore.css rename to DiscordChatExporter.Domain/Exporting/Writers/Html/Core.css diff --git a/DiscordChatExporter.Domain/Exporting/Resources/HtmlDark.css b/DiscordChatExporter.Domain/Exporting/Writers/Html/Dark.css similarity index 100% rename from DiscordChatExporter.Domain/Exporting/Resources/HtmlDark.css rename to DiscordChatExporter.Domain/Exporting/Writers/Html/Dark.css diff --git a/DiscordChatExporter.Domain/Exporting/Resources/HtmlLayoutTemplate.html b/DiscordChatExporter.Domain/Exporting/Writers/Html/LayoutTemplate.html similarity index 67% rename from DiscordChatExporter.Domain/Exporting/Resources/HtmlLayoutTemplate.html rename to DiscordChatExporter.Domain/Exporting/Writers/Html/LayoutTemplate.html index ff20806..6f11eb6 100644 --- a/DiscordChatExporter.Domain/Exporting/Resources/HtmlLayoutTemplate.html +++ b/DiscordChatExporter.Domain/Exporting/Writers/Html/LayoutTemplate.html @@ -3,7 +3,7 @@ {{~ # Metadata ~}} - {{ Context.Guild.Name | html.escape }} - {{ Context.Channel.Name | html.escape }} + {{ Context.Request.Guild.Name | html.escape }} - {{ Context.Request.Channel.Name | html.escape }} @@ -58,24 +58,24 @@ {{~ # Preamble ~}}
- Guild icon + Guild icon
-
{{ Context.Guild.Name | html.escape }}
-
{{ Context.Channel.Category | html.escape }} / {{ Context.Channel.Name | html.escape }}
+
{{ Context.Request.Guild.Name | html.escape }}
+
{{ Context.Request.Channel.Category | html.escape }} / {{ Context.Request.Channel.Name | html.escape }}
- {{~ if Context.Channel.Topic ~}} -
{{ Context.Channel.Topic | html.escape }}
+ {{~ if Context.Request.Channel.Topic ~}} +
{{ Context.Request.Channel.Topic | html.escape }}
{{~ end ~}} - {{~ if Context.After || Context.Before ~}} + {{~ if Context.Request.After || Context.Request.Before ~}}
- {{~ if Context.After && Context.Before ~}} - Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }} - {{~ else if Context.After ~}} - After {{ Context.After | FormatDate | html.escape }} - {{~ else if Context.Before ~}} - Before {{ Context.Before | FormatDate | html.escape }} + {{~ if Context.Request.After && Context.Request.Before ~}} + Between {{ Context.Request.After | FormatDate | html.escape }} and {{ Context.Request.Before | FormatDate | html.escape }} + {{~ else if Context.Request.After ~}} + After {{ Context.Request.After | FormatDate | html.escape }} + {{~ else if Context.Request.Before ~}} + Before {{ Context.Request.Before | FormatDate | html.escape }} {{~ end ~}}
{{~ end ~}} diff --git a/DiscordChatExporter.Domain/Exporting/Resources/HtmlLight.css b/DiscordChatExporter.Domain/Exporting/Writers/Html/Light.css similarity index 100% rename from DiscordChatExporter.Domain/Exporting/Resources/HtmlLight.css rename to DiscordChatExporter.Domain/Exporting/Writers/Html/Light.css diff --git a/DiscordChatExporter.Domain/Exporting/MessageGroup.cs b/DiscordChatExporter.Domain/Exporting/Writers/Html/MessageGroup.cs similarity index 67% rename from DiscordChatExporter.Domain/Exporting/MessageGroup.cs rename to DiscordChatExporter.Domain/Exporting/Writers/Html/MessageGroup.cs index 4c1b165..e747f51 100644 --- a/DiscordChatExporter.Domain/Exporting/MessageGroup.cs +++ b/DiscordChatExporter.Domain/Exporting/Writers/Html/MessageGroup.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using DiscordChatExporter.Domain.Discord.Models; -namespace DiscordChatExporter.Domain.Exporting +namespace DiscordChatExporter.Domain.Exporting.Writers.Html { // Used for grouping contiguous messages in HTML export internal partial class MessageGroup @@ -23,9 +24,20 @@ namespace DiscordChatExporter.Domain.Exporting internal partial class MessageGroup { - public static bool CanGroup(Message message1, Message message2) => + public static bool CanJoin(Message message1, Message message2) => string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal) && string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) && (message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7; + + public static MessageGroup Join(IReadOnlyList messages) + { + var first = messages.First(); + + return new MessageGroup( + first.Author, + first.Timestamp, + messages + ); + } } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/Resources/HtmlMessageGroupTemplate.html b/DiscordChatExporter.Domain/Exporting/Writers/Html/MessageGroupTemplate.html similarity index 92% rename from DiscordChatExporter.Domain/Exporting/Resources/HtmlMessageGroupTemplate.html rename to DiscordChatExporter.Domain/Exporting/Writers/Html/MessageGroupTemplate.html index 0550f5b..71d5657 100644 --- a/DiscordChatExporter.Domain/Exporting/Resources/HtmlMessageGroupTemplate.html +++ b/DiscordChatExporter.Domain/Exporting/Writers/Html/MessageGroupTemplate.html @@ -1,7 +1,7 @@
{{~ # Avatar ~}}
- Avatar + Avatar
{{~ # Author name and timestamp ~}} @@ -39,16 +39,16 @@ {{~ if attachment.IsSpoiler ~}} {{~ else ~}} - + {{ # Non-spoiler image }} {{~ if attachment.IsImage ~}} - Attachment + Attachment {{~ # Non-image ~}} {{~ else ~}} Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }}) @@ -73,7 +73,7 @@ {{~ if embed.Author ~}}
{{~ if embed.Author.IconUrl ~}} - Author icon + Author icon {{~ end ~}} {{~ if embed.Author.Name ~}} @@ -124,8 +124,8 @@ {{~ # Thumbnail ~}} {{~ if embed.Thumbnail ~}} {{~ end ~}} @@ -134,8 +134,8 @@ {{~ # Image ~}} {{~ if embed.Image ~}} {{~ end ~}} @@ -145,7 +145,7 @@