From 2463cb50871c6e70641b185f77226edfc17c95d9 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Tue, 28 Jun 2022 17:23:01 +0300 Subject: [PATCH] Sort DMs by last message and avoid grouping them in GUI Closes #271 --- .../GetDirectMessageChannelsCommand.cs | 2 +- .../Discord/Data/Attachment.cs | 4 +-- .../Discord/Data/Channel.cs | 20 +++++++++--- .../Discord/Data/ChannelCategory.cs | 5 ++- .../Discord/Data/Embeds/Embed.cs | 10 +++--- .../Discord/Data/Emoji.cs | 1 - .../Discord/Data/Guild.cs | 2 +- .../Discord/Data/Message.cs | 15 ++++++--- .../Discord/Data/MessageReference.cs | 17 ++++++++-- .../Discord/Data/Sticker.cs | 1 - DiscordChatExporter.Core/Discord/Data/User.cs | 4 +-- .../Discord/DiscordClient.cs | 2 ++ DiscordChatExporter.Core/Discord/Snowflake.cs | 10 +++++- .../SnowflakeToDateTimeOffsetConverter.cs | 20 ++++++++++++ .../Components/DashboardViewModel.cs | 2 ++ .../Views/Components/DashboardView.xaml | 31 +++++++++++++++++-- .../Views/Dialogs/ExportSetupView.xaml | 15 ++++----- 17 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs diff --git a/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs b/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs index e7bc31d..cda9afd 100644 --- a/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs @@ -20,7 +20,7 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase var textChannels = channels .Where(c => c.Kind.IsText()) - .OrderBy(c => c.Category.Position) + .OrderByDescending(c => c.LastMessageId) .ThenBy(c => c.Name) .ToArray(); diff --git a/DiscordChatExporter.Core/Discord/Data/Attachment.cs b/DiscordChatExporter.Core/Discord/Data/Attachment.cs index 4020cc1..1a3e0de 100644 --- a/DiscordChatExporter.Core/Discord/Data/Attachment.cs +++ b/DiscordChatExporter.Core/Discord/Data/Attachment.cs @@ -35,10 +35,10 @@ public partial record Attachment { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var url = json.GetProperty("url").GetNonWhiteSpaceString(); - var width = json.GetPropertyOrNull("width")?.GetInt32OrNull(); - var height = json.GetPropertyOrNull("height")?.GetInt32OrNull(); var fileName = json.GetProperty("filename").GetNonNullString(); var description = json.GetPropertyOrNull("description")?.GetNonWhiteSpaceStringOrNull(); + var width = json.GetPropertyOrNull("width")?.GetInt32OrNull(); + var height = json.GetPropertyOrNull("height")?.GetInt32OrNull(); var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes); return new Attachment(id, url, fileName, description, width, height, fileSize); diff --git a/DiscordChatExporter.Core/Discord/Data/Channel.cs b/DiscordChatExporter.Core/Discord/Data/Channel.cs index 8b3f7cf..b5862e0 100644 --- a/DiscordChatExporter.Core/Discord/Data/Channel.cs +++ b/DiscordChatExporter.Core/Discord/Data/Channel.cs @@ -14,7 +14,8 @@ public partial record Channel( ChannelCategory Category, string Name, int? Position, - string? Topic + string? Topic, + Snowflake? LastMessageId ) : IHasId; public partial record Channel @@ -36,9 +37,8 @@ public partial record Channel public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); - var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); - var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull(); var kind = (ChannelKind)json.GetProperty("type").GetInt32(); + var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); var name = // Guild channel @@ -54,7 +54,16 @@ public partial record Channel // Fallback id.ToString(); - var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull(); + var position = + positionHint ?? + json.GetPropertyOrNull("position")?.GetInt32OrNull(); + + var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull(); + + var lastMessageId = json + .GetPropertyOrNull("last_message_id")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(Snowflake.Parse); return new Channel( id, @@ -63,7 +72,8 @@ public partial record Channel category ?? GetFallbackCategory(kind), name, position, - topic + topic, + lastMessageId ); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs b/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs index 15e9744..90ce05b 100644 --- a/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs +++ b/DiscordChatExporter.Core/Discord/Data/ChannelCategory.cs @@ -12,12 +12,15 @@ public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId public static ChannelCategory Parse(JsonElement json, int? positionHint = null) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); - var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull(); var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? id.ToString(); + var position = + positionHint ?? + json.GetPropertyOrNull("position")?.GetInt32OrNull(); + return new ChannelCategory(id, name, position); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs index 9d3c4eb..15d333d 100644 --- a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs @@ -39,17 +39,17 @@ public partial record Embed var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset(); var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha(); - var description = json.GetPropertyOrNull("description")?.GetStringOrNull(); - var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse); - var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); - var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse); - var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse); + var description = json.GetPropertyOrNull("description")?.GetStringOrNull(); var fields = json.GetPropertyOrNull("fields")?.EnumerateArrayOrNull()?.Select(EmbedField.Parse).ToArray() ?? Array.Empty(); + var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); + var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse); + var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse); + return new Embed( title, url, diff --git a/DiscordChatExporter.Core/Discord/Data/Emoji.cs b/DiscordChatExporter.Core/Discord/Data/Emoji.cs index bef2a41..5c28bb3 100644 --- a/DiscordChatExporter.Core/Discord/Data/Emoji.cs +++ b/DiscordChatExporter.Core/Discord/Data/Emoji.cs @@ -64,7 +64,6 @@ public partial record Emoji var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull(); var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false; - var imageUrl = GetImageUrl(id, name, isAnimated); return new Emoji( diff --git a/DiscordChatExporter.Core/Discord/Data/Guild.cs b/DiscordChatExporter.Core/Discord/Data/Guild.cs index b83ab53..05e6870 100644 --- a/DiscordChatExporter.Core/Discord/Data/Guild.cs +++ b/DiscordChatExporter.Core/Discord/Data/Guild.cs @@ -24,8 +24,8 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var name = json.GetProperty("name").GetNonNullString(); - var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull(); + var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull(); var iconUrl = !string.IsNullOrWhiteSpace(iconHash) ? GetIconUrl(id, iconHash) : GetDefaultIconUrl(); diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 3299936..de28907 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -30,15 +30,17 @@ public record Message( public static Message Parse(JsonElement json) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); + var kind = (MessageKind)json.GetProperty("type").GetInt32(); var author = json.GetProperty("author").Pipe(User.Parse); + var timestamp = json.GetProperty("timestamp").GetDateTimeOffset(); var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset(); - var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp") - ?.GetDateTimeOffset(); - var kind = (MessageKind)json.GetProperty("type").GetInt32(); + var callEndedTimestamp = json + .GetPropertyOrNull("call")? + .GetPropertyOrNull("ended_timestamp")? + .GetDateTimeOffset(); + var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false; - var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse); - var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); var content = kind switch { @@ -73,6 +75,9 @@ public record Message( json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() ?? Array.Empty(); + var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse); + var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); + return new Message( id, kind, diff --git a/DiscordChatExporter.Core/Discord/Data/MessageReference.cs b/DiscordChatExporter.Core/Discord/Data/MessageReference.cs index 54ff65c..d868a37 100644 --- a/DiscordChatExporter.Core/Discord/Data/MessageReference.cs +++ b/DiscordChatExporter.Core/Discord/Data/MessageReference.cs @@ -9,9 +9,20 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf { public static MessageReference Parse(JsonElement json) { - var messageId = json.GetPropertyOrNull("message_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); - var channelId = json.GetPropertyOrNull("channel_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); - var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); + var messageId = json + .GetPropertyOrNull("message_id")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(Snowflake.Parse); + + var channelId = json + .GetPropertyOrNull("channel_id")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(Snowflake.Parse); + + var guildId = json + .GetPropertyOrNull("guild_id")? + .GetNonWhiteSpaceStringOrNull()? + .Pipe(Snowflake.Parse); return new MessageReference(messageId, channelId, guildId); } diff --git a/DiscordChatExporter.Core/Discord/Data/Sticker.cs b/DiscordChatExporter.Core/Discord/Data/Sticker.cs index 5970a96..aadd4d9 100644 --- a/DiscordChatExporter.Core/Discord/Data/Sticker.cs +++ b/DiscordChatExporter.Core/Discord/Data/Sticker.cs @@ -18,7 +18,6 @@ public record Sticker(Snowflake Id, string Name, StickerFormat Format, string So var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var name = json.GetProperty("name").GetNonNullString(); var format = (StickerFormat)json.GetProperty("format_type").GetInt32(); - var sourceUrl = GetSourceUrl(id, format); return new Sticker(id, name, format, sourceUrl); diff --git a/DiscordChatExporter.Core/Discord/Data/User.cs b/DiscordChatExporter.Core/Discord/Data/User.cs index e136cbf..ec16f5a 100644 --- a/DiscordChatExporter.Core/Discord/Data/User.cs +++ b/DiscordChatExporter.Core/Discord/Data/User.cs @@ -39,12 +39,12 @@ public partial record User var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false; var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse); var name = json.GetProperty("username").GetNonNullString(); - var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull(); + var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull(); 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.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 6086728..51b194c 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -181,6 +181,8 @@ public class DiscordClient .Select((j, index) => ChannelCategory.Parse(j, index + 1)) .ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal); + // Discord positions are not deterministic, so we need to normalize them + // because the user may refer to the channel position via file name template. var position = 0; foreach (var channelJson in responseOrdered) diff --git a/DiscordChatExporter.Core/Discord/Snowflake.cs b/DiscordChatExporter.Core/Discord/Snowflake.cs index 38c75d2..56897c9 100644 --- a/DiscordChatExporter.Core/Discord/Snowflake.cs +++ b/DiscordChatExporter.Core/Discord/Snowflake.cs @@ -49,7 +49,15 @@ public partial record struct Snowflake public static Snowflake Parse(string str) => Parse(str, null); } -public partial record struct Snowflake : IComparable +public partial record struct Snowflake : IComparable, IComparable { public int CompareTo(Snowflake other) => Value.CompareTo(other.Value); + + public int CompareTo(object? obj) + { + if (obj is not Snowflake other) + throw new ArgumentException($"Object must be of type {nameof(Snowflake)}."); + + return Value.CompareTo(other.Value); + } } \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs b/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs new file mode 100644 index 0000000..1f8198a --- /dev/null +++ b/DiscordChatExporter.Gui/Converters/SnowflakeToDateTimeOffsetConverter.cs @@ -0,0 +1,20 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using DiscordChatExporter.Core.Discord; + +namespace DiscordChatExporter.Gui.Converters; + +[ValueConversion(typeof(Snowflake?), typeof(DateTimeOffset?))] +public class SnowflakeToDateTimeOffsetConverter : IValueConverter +{ + public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new(); + + public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => + value is Snowflake snowflake + ? snowflake.ToDate() + : null; + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + throw new NotSupportedException(); +} \ No newline at end of file diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index b6dded1..8f6daf2 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -44,6 +44,8 @@ public class DashboardViewModel : PropertyChangedBase public Guild? SelectedGuild { get; set; } + public bool IsDirectMessageGuildSelected => SelectedGuild?.Id == Guild.DirectMessages.Id; + public IReadOnlyList? AvailableChannels => SelectedGuild is not null ? GuildChannelMap?[SelectedGuild] : null; diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml index 6b380f7..774bee7 100644 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml @@ -8,6 +8,8 @@ xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core" + xmlns:globalization="clr-namespace:System.Globalization;assembly=System.Runtime" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:s="https://github.com/canton7/Stylet" @@ -16,6 +18,14 @@ Loaded="{s:Action OnViewLoaded}" mc:Ignorable="d"> + + + + + + + + @@ -274,13 +284,24 @@ + + + @@ -306,7 +327,7 @@ - + @@ -316,6 +337,12 @@ + + + + + + - + @@ -110,8 +111,8 @@ Margin="16,8,16,4" materialDesign:HintAssist.Hint="After (date)" materialDesign:HintAssist.IsFloating="True" - DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" - SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" + DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}" + SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}" Style="{DynamicResource MaterialDesignOutlinedDatePicker}" ToolTip="Only include messages sent after this date" />