From 9d0d7cd5ddb0b4dedad9c946e6b6745c09e2cee5 Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Thu, 23 Apr 2020 00:32:48 +0300 Subject: [PATCH] More refactoring --- .../Discord/AuthToken.cs | 2 +- .../Discord/DiscordClient.Parsing.cs | 73 +++++++++++----- .../Discord/DiscordClient.cs | 87 ++++++++++--------- .../Discord/UrlBuilder.cs | 50 +++++++++++ .../Exporting/ChannelExporter.cs | 21 +++-- .../Exporting/MessageExporter.cs | 2 +- DiscordChatExporter.Gui/FodyWeavers.xsd | 10 +++ .../ViewModels/RootViewModel.cs | 79 ++++++----------- 8 files changed, 197 insertions(+), 127 deletions(-) create mode 100644 DiscordChatExporter.Domain/Discord/UrlBuilder.cs diff --git a/DiscordChatExporter.Domain/Discord/AuthToken.cs b/DiscordChatExporter.Domain/Discord/AuthToken.cs index 9327414b..e9d1adeb 100644 --- a/DiscordChatExporter.Domain/Discord/AuthToken.cs +++ b/DiscordChatExporter.Domain/Discord/AuthToken.cs @@ -16,7 +16,7 @@ namespace DiscordChatExporter.Domain.Discord Value = value; } - public AuthenticationHeaderValue GetAuthenticationHeader() => Type == AuthTokenType.User + public AuthenticationHeaderValue GetAuthorizationHeader() => Type == AuthTokenType.User ? new AuthenticationHeaderValue(Value) : new AuthenticationHeaderValue("Bot", Value); diff --git a/DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs b/DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs index a62846a4..0b7d858e 100644 --- a/DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs +++ b/DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs @@ -29,10 +29,12 @@ namespace DiscordChatExporter.Domain.Discord { var userId = json.GetProperty("user").Pipe(ParseId); var nick = json.GetPropertyOrNull("nick")?.GetString(); - var roles = json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ?? - Array.Empty(); - return new Member(userId, nick, roles); + var roleIds = + json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ?? + Array.Empty(); + + return new Member(userId, nick, roleIds); } private Guild ParseGuild(JsonElement json) @@ -40,8 +42,10 @@ namespace DiscordChatExporter.Domain.Discord var id = ParseId(json); var name = json.GetProperty("name").GetString(); var iconHash = json.GetProperty("icon").GetString(); - var roles = json.GetPropertyOrNull("roles")?.EnumerateArray().Select(ParseRole).ToArray() ?? - Array.Empty(); + + var roles = + json.GetPropertyOrNull("roles")?.EnumerateArray().Select(ParseRole).ToArray() ?? + Array.Empty(); return new Guild(id, name, iconHash, roles); } @@ -53,8 +57,9 @@ namespace DiscordChatExporter.Domain.Discord var type = (ChannelType) json.GetProperty("type").GetInt32(); var topic = json.GetPropertyOrNull("topic")?.GetString(); - var guildId = json.GetPropertyOrNull("guild_id")?.GetString() ?? - Guild.DirectMessages.Id; + var guildId = + json.GetPropertyOrNull("guild_id")?.GetString() ?? + Guild.DirectMessages.Id; var name = json.GetPropertyOrNull("name")?.GetString() ?? @@ -134,10 +139,22 @@ namespace DiscordChatExporter.Domain.Discord var image = json.GetPropertyOrNull("image")?.Pipe(ParseEmbedImage); var footer = json.GetPropertyOrNull("footer")?.Pipe(ParseEmbedFooter); - var fields = json.GetPropertyOrNull("fields")?.EnumerateArray().Select(ParseEmbedField).ToArray() ?? - Array.Empty(); + var fields = + json.GetPropertyOrNull("fields")?.EnumerateArray().Select(ParseEmbedField).ToArray() ?? + Array.Empty(); - return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer); + return new Embed( + title, + url, + timestamp, + color, + author, + description, + fields, + thumbnail, + image, + footer + ); } private Emoji ParseEmoji(JsonElement json) @@ -180,20 +197,36 @@ namespace DiscordChatExporter.Domain.Discord var author = json.GetProperty("author").Pipe(ParseUser); - var attachments = json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(ParseAttachment).ToArray() ?? - Array.Empty(); + var attachments = + json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(ParseAttachment).ToArray() ?? + Array.Empty(); - var embeds = json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(ParseEmbed).ToArray() ?? - Array.Empty(); + var embeds = + json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(ParseEmbed).ToArray() ?? + Array.Empty(); - var reactions = json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(ParseReaction).ToArray() ?? - Array.Empty(); + var reactions = + json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(ParseReaction).ToArray() ?? + Array.Empty(); - var mentionedUsers = json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(ParseUser).ToArray() ?? - Array.Empty(); + var mentionedUsers = + json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(ParseUser).ToArray() ?? + Array.Empty(); - return new Message(id, channelId, type, author, timestamp, editedTimestamp, isPinned, content, attachments, embeds, - reactions, mentionedUsers); + return new Message( + id, + channelId, + type, + author, + timestamp, + editedTimestamp, + isPinned, + content, + attachments, + embeds, + reactions, + mentionedUsers + ); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Discord/DiscordClient.cs b/DiscordChatExporter.Domain/Discord/DiscordClient.cs index 127d24a7..9b81c884 100644 --- a/DiscordChatExporter.Domain/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Domain/Discord/DiscordClient.cs @@ -18,6 +18,8 @@ namespace DiscordChatExporter.Domain.Discord private readonly HttpClient _httpClient; private readonly IAsyncPolicy _httpRequestPolicy; + private readonly Uri _baseUri = new Uri("https://discordapp.com/api/v6/", UriKind.Absolute); + public DiscordClient(AuthToken token, HttpClient httpClient) { _token = token; @@ -51,10 +53,8 @@ namespace DiscordChatExporter.Domain.Discord { using var response = await _httpRequestPolicy.ExecuteAsync(async () => { - var uri = new Uri(new Uri("https://discordapp.com/api/v6"), url); - - using var request = new HttpRequestMessage(HttpMethod.Get, uri); - request.Headers.Authorization = _token.GetAuthenticationHeader(); + using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); + request.Headers.Authorization = _token.GetAuthorizationHeader(); return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); }); @@ -113,11 +113,13 @@ namespace DiscordChatExporter.Domain.Discord while (true) { - var route = "users/@me/guilds?limit=100"; - if (!string.IsNullOrWhiteSpace(afterId)) - route += $"&after={afterId}"; + var url = new UrlBuilder() + .SetPath("users/@me/guilds") + .SetQueryParameter("limit", "100") + .SetQueryParameterIfNotNullOrWhiteSpace("after", afterId) + .Build(); - var response = await GetApiResponseAsync(route); + var response = await GetApiResponseAsync(url); var isEmpty = true; @@ -147,7 +149,7 @@ namespace DiscordChatExporter.Domain.Discord public async Task> GetGuildChannelsAsync(string guildId) { - // Special case for direct messages pseudo-guild + // Direct messages pseudo-guild if (guildId == Guild.DirectMessages.Id) return Array.Empty(); @@ -159,38 +161,42 @@ namespace DiscordChatExporter.Domain.Discord private async Task GetLastMessageAsync(string channelId, DateTimeOffset? before = null) { - var route = $"channels/{channelId}/messages?limit=1"; - if (before != null) - route += $"&before={before.Value.ToSnowflake()}"; + var url = new UrlBuilder() + .SetPath($"channels/{channelId}/messages") + .SetQueryParameter("limit", "1") + .SetQueryParameterIfNotNullOrWhiteSpace("before", before?.ToSnowflake()) + .Build(); - var response = await GetApiResponseAsync(route); + var response = await GetApiResponseAsync(url); return response.EnumerateArray().Select(ParseMessage).FirstOrDefault(); } - public async IAsyncEnumerable GetMessagesAsync(string channelId, - DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress? progress = null) + public async IAsyncEnumerable GetMessagesAsync( + string channelId, + DateTimeOffset? after = null, + DateTimeOffset? before = null, + IProgress? progress = null) { - // Get the last message var lastMessage = await GetLastMessageAsync(channelId, before); // If the last message doesn't exist or it's outside of range - return if (lastMessage == null || lastMessage.Timestamp < after) - { - progress?.Report(1); yield break; - } - // Get other messages var firstMessage = default(Message); var afterId = after?.ToSnowflake() ?? "0"; + while (true) { - // Get message batch - var route = $"channels/{channelId}/messages?limit=100&after={afterId}"; - var response = await GetApiResponseAsync(route); + var url = new UrlBuilder() + .SetPath($"channels/{channelId}/messages") + .SetQueryParameter("limit", "100") + .SetQueryParameter("after", afterId) + .Build(); + + var response = await GetApiResponseAsync(url); - // Parse var messages = response .EnumerateArray() .Select(ParseMessage) @@ -201,33 +207,28 @@ namespace DiscordChatExporter.Domain.Discord if (!messages.Any()) break; - // Trim messages to range (until last message) - var messagesInRange = messages - .TakeWhile(m => m.Id != lastMessage.Id && m.Timestamp < lastMessage.Timestamp) - .ToArray(); - - // Yield messages - foreach (var message in messagesInRange) + foreach (var message in messages) { - // Set first message if it's not set firstMessage ??= message; - // Report progress (based on the time range of parsed messages compared to total) - progress?.Report((message.Timestamp - firstMessage.Timestamp).TotalSeconds / - (lastMessage.Timestamp - firstMessage.Timestamp).TotalSeconds); + // Ensure messages are in range (take into account that last message could have been deleted) + if (message.Timestamp > lastMessage.Timestamp) + yield break; + + // Report progress based on the duration of parsed messages divided by total + progress?.Report( + (message.Timestamp - firstMessage.Timestamp) / + (lastMessage.Timestamp - firstMessage.Timestamp) + ); yield return message; afterId = message.Id; + + // Yielded last message - break loop + if (message.Id == lastMessage.Id) + yield break; } - - // Break if messages were trimmed (which means the last message was encountered) - if (messagesInRange.Length != messages.Length) - break; } - - // Yield last message - yield return lastMessage; - progress?.Report(1); } } diff --git a/DiscordChatExporter.Domain/Discord/UrlBuilder.cs b/DiscordChatExporter.Domain/Discord/UrlBuilder.cs new file mode 100644 index 00000000..1b1b6e8c --- /dev/null +++ b/DiscordChatExporter.Domain/Discord/UrlBuilder.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; + +namespace DiscordChatExporter.Domain.Discord +{ + internal class UrlBuilder + { + private string _path = ""; + + private readonly Dictionary _queryParameters = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public UrlBuilder SetPath(string path) + { + _path = path; + return this; + } + + public UrlBuilder SetQueryParameter(string key, string? value) + { + var keyEncoded = WebUtility.UrlEncode(key); + var valueEncoded = WebUtility.UrlEncode(value); + _queryParameters[keyEncoded] = valueEncoded; + + return this; + } + + public UrlBuilder SetQueryParameterIfNotNullOrWhiteSpace(string key, string? value) => + !string.IsNullOrWhiteSpace(value) + ? SetQueryParameter(key, value) + : this; + + public string Build() + { + var buffer = new StringBuilder(); + + buffer.Append(_path); + + if (_queryParameters.Any()) + buffer.Append('?'); + + buffer.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}")); + + return buffer.ToString(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs b/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs index dc0f4b5c..c528bd77 100644 --- a/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Domain/Exporting/ChannelExporter.cs @@ -17,6 +17,8 @@ namespace DiscordChatExporter.Domain.Exporting public ChannelExporter(DiscordClient discord) => _discord = discord; + public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {} + public async Task ExportAsync( Guild guild, Channel channel, @@ -28,13 +30,12 @@ namespace DiscordChatExporter.Domain.Exporting DateTimeOffset? before = null, IProgress? progress = null) { - // Get base file path from output path var baseFilePath = GetFilePathFromOutputPath(guild, channel, outputPath, format, after, before); - // Create options + // Options var options = new ExportOptions(baseFilePath, format, partitionLimit); - // Create context + // Context var mentionableUsers = new HashSet(IdBasedEqualityComparer.Instance); var mentionableChannels = await _discord.GetGuildChannelsAsync(guild.Id); var mentionableRoles = guild.Roles; @@ -44,11 +45,9 @@ namespace DiscordChatExporter.Domain.Exporting mentionableUsers, mentionableChannels, mentionableRoles ); - // Create renderer - await using var renderer = new MessageExporter(options, context); + await using var messageExporter = new MessageExporter(options, context); - // Render messages - var renderedAnything = false; + var exportedAnything = false; await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress)) { // Add encountered users to the list of mentionable users @@ -68,12 +67,12 @@ namespace DiscordChatExporter.Domain.Exporting } // Render message - await renderer.RenderMessageAsync(message); - renderedAnything = true; + await messageExporter.ExportMessageAsync(message); + exportedAnything = true; } - // Throw if no messages were rendered - if (!renderedAnything) + // Throw if no messages were exported + if (!exportedAnything) throw DiscordChatExporterException.ChannelEmpty(channel); } } diff --git a/DiscordChatExporter.Domain/Exporting/MessageExporter.cs b/DiscordChatExporter.Domain/Exporting/MessageExporter.cs index 80dd5bc9..d7c8566f 100644 --- a/DiscordChatExporter.Domain/Exporting/MessageExporter.cs +++ b/DiscordChatExporter.Domain/Exporting/MessageExporter.cs @@ -62,7 +62,7 @@ namespace DiscordChatExporter.Domain.Exporting return _writer = writer; } - public async Task RenderMessageAsync(Message message) + public async Task ExportMessageAsync(Message message) { var writer = await GetWriterAsync(); await writer.WriteMessageAsync(message); diff --git a/DiscordChatExporter.Gui/FodyWeavers.xsd b/DiscordChatExporter.Gui/FodyWeavers.xsd index 2f1b8aae..221aeb8a 100644 --- a/DiscordChatExporter.Gui/FodyWeavers.xsd +++ b/DiscordChatExporter.Gui/FodyWeavers.xsd @@ -31,6 +31,16 @@ Used to control if equality checks should use the static Equals method resolved from the base class. + + + Used to turn off build warnings from this weaver. + + + + + Used to turn off build warnings about mismatched On_PropertyName_Changed methods. + + diff --git a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs index 39ca52fe..10eb2314 100644 --- a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs @@ -54,35 +54,28 @@ namespace DiscordChatExporter.Gui.ViewModels _settingsService = settingsService; _updateService = updateService; - // Set title DisplayName = $"{App.Name} v{App.VersionString}"; // Update busy state when progress manager changes - ProgressManager.Bind(o => o.IsActive, (sender, args) => IsBusy = ProgressManager.IsActive); + ProgressManager.Bind(o => o.IsActive, + (sender, args) => IsBusy = ProgressManager.IsActive); ProgressManager.Bind(o => o.IsActive, (sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); ProgressManager.Bind(o => o.Progress, (sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); } - private DiscordClient GetDiscordClient(AuthToken token) => new DiscordClient(token); - - private ChannelExporter GetChannelExporter(AuthToken token) => new ChannelExporter(GetDiscordClient(token)); - private async Task HandleAutoUpdateAsync() { try { - // Check for updates var updateVersion = await _updateService.CheckForUpdatesAsync(); if (updateVersion == null) return; - // Notify user of an update and prepare it Notifications.Enqueue($"Downloading update to {App.Name} v{updateVersion}..."); await _updateService.PrepareUpdateAsync(updateVersion); - // Prompt user to install update (otherwise install it when application exits) Notifications.Enqueue( "Update has been downloaded and will be installed when you exit", "INSTALL NOW", () => @@ -102,17 +95,14 @@ namespace DiscordChatExporter.Gui.ViewModels { base.OnViewLoaded(); - // Load settings _settingsService.Load(); - // Get last token if (_settingsService.LastToken != null) { IsBotToken = _settingsService.LastToken.Type == AuthTokenType.Bot; TokenValue = _settingsService.LastToken.Value; } - // Check and prepare update await HandleAutoUpdateAsync(); } @@ -120,49 +110,44 @@ namespace DiscordChatExporter.Gui.ViewModels { base.OnClose(); - // Save settings _settingsService.Save(); - - // Finalize updates if necessary _updateService.FinalizeUpdate(false); } public async void ShowSettings() { - // Create dialog var dialog = _viewModelFactory.CreateSettingsViewModel(); - - // Show dialog await _dialogManager.ShowDialogAsync(dialog); } - public bool CanPopulateGuildsAndChannels => !IsBusy && !string.IsNullOrWhiteSpace(TokenValue); + public bool CanPopulateGuildsAndChannels => + !IsBusy && !string.IsNullOrWhiteSpace(TokenValue); public async void PopulateGuildsAndChannels() { - // Create progress operation - var operation = ProgressManager.CreateOperation(); + using var operation = ProgressManager.CreateOperation(); try { - // Sanitize token - TokenValue = TokenValue!.Trim('"'); + var tokenValue = TokenValue?.Trim('"'); + if (string.IsNullOrWhiteSpace(tokenValue)) + return; - // Create token var token = new AuthToken( IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, - TokenValue); + tokenValue + ); - // Save token _settingsService.LastToken = token; - // Prepare available guild list + var discord = new DiscordClient(token); + var availableGuilds = new List(); - // Get direct messages + // Direct messages { var guild = Guild.DirectMessages; - var channels = await GetDiscordClient(token).GetDirectMessageChannelsAsync(); + var channels = await discord.GetDirectMessageChannelsAsync(); // Create channel view models var channelViewModels = new List(); @@ -188,11 +173,11 @@ namespace DiscordChatExporter.Gui.ViewModels availableGuilds.Add(guildViewModel); } - // Get guilds - var guilds = await GetDiscordClient(token).GetUserGuildsAsync(); + // Guilds + var guilds = await discord.GetUserGuildsAsync(); foreach (var guild in guilds) { - var channels = await GetDiscordClient(token).GetGuildChannelsAsync(guild.Id); + var channels = await discord.GetGuildChannelsAsync(guild.Id); var categoryChannels = channels.Where(c => c.Type == ChannelType.GuildCategory).ToArray(); var exportableChannels = channels.Where(c => c.IsTextChannel).ToArray(); @@ -220,40 +205,32 @@ namespace DiscordChatExporter.Gui.ViewModels availableGuilds.Add(guildViewModel); } - // Update available guild list AvailableGuilds = availableGuilds; - - // Pre-select first guild SelectedGuild = AvailableGuilds.FirstOrDefault(); } catch (DiscordChatExporterException ex) when (!ex.IsCritical) { - Notifications.Enqueue(ex.Message); - } - finally - { - operation.Dispose(); + Notifications.Enqueue(ex.Message.TrimEnd('.')); } } - public bool CanExportChannels => !IsBusy && SelectedGuild != null && SelectedChannels != null && SelectedChannels.Any(); + public bool CanExportChannels => + !IsBusy && SelectedGuild != null && SelectedChannels != null && SelectedChannels.Any(); public async void ExportChannels() { - // Get last used token - var token = _settingsService.LastToken!; + var token = _settingsService.LastToken; + if (token == null || SelectedGuild == null || SelectedChannels == null || !SelectedChannels.Any()) + return; - // Create dialog - var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild!, SelectedChannels!); - - // Show dialog, if canceled - return + var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels); if (await _dialogManager.ShowDialogAsync(dialog) != true) return; - // Create a progress operation for each channel to export + var exporter = new ChannelExporter(token); + var operations = ProgressManager.CreateOperations(dialog.Channels!.Count); - // Export channels var successfulExportCount = 0; await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple => { @@ -261,7 +238,7 @@ namespace DiscordChatExporter.Gui.ViewModels try { - await GetChannelExporter(token).ExportAsync(dialog.Guild!, channel!, + await exporter.ExportAsync(dialog.Guild!, channel!, dialog.OutputPath!, dialog.SelectedFormat, _settingsService.DateFormat, dialog.PartitionLimit, dialog.After, dialog.Before, operation); @@ -269,7 +246,7 @@ namespace DiscordChatExporter.Gui.ViewModels } catch (DiscordChatExporterException ex) when (!ex.IsCritical) { - Notifications.Enqueue(ex.Message); + Notifications.Enqueue(ex.Message.TrimEnd('.')); } finally {