More refactoring

This commit is contained in:
Alexey Golub 2020-04-24 14:18:41 +03:00
parent 9d0d7cd5dd
commit d03be8b1dd
43 changed files with 617 additions and 655 deletions

View file

@ -1,8 +1,9 @@
using System.Linq; using System.Threading.Tasks;
using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
{ {
@ -11,10 +12,8 @@ namespace DiscordChatExporter.Cli.Commands
{ {
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
var directMessageChannels = await GetDiscordClient().GetDirectMessageChannelsAsync(); var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
var channels = directMessageChannels.OrderBy(c => c.Name).ToArray(); await ExportMultipleAsync(console, dmChannels);
await ExportMultipleAsync(console, channels);
} }
} }
} }

View file

@ -1,8 +1,8 @@
using System.Linq; using System.Threading.Tasks;
using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
{ {
@ -15,13 +15,7 @@ namespace DiscordChatExporter.Cli.Commands
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId); var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
await ExportMultipleAsync(console, guildChannels);
var channels = guildChannels
.Where(c => c.IsTextChannel)
.OrderBy(c => c.Name)
.ToArray();
await ExportMultipleAsync(console, channels);
} }
} }
} }

View file

@ -3,6 +3,7 @@ using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
{ {
@ -16,12 +17,7 @@ namespace DiscordChatExporter.Cli.Commands
{ {
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId); var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
var channels = guildChannels foreach (var channel in guildChannels.OrderBy(c => c.Name))
.Where(c => c.IsTextChannel)
.OrderBy(c => c.Name)
.ToArray();
foreach (var channel in channels)
console.Output.WriteLine($"{channel.Id} | {channel.Name}"); console.Output.WriteLine($"{channel.Id} | {channel.Name}");
} }
} }

View file

@ -3,6 +3,8 @@ using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
{ {
@ -11,10 +13,9 @@ namespace DiscordChatExporter.Cli.Commands
{ {
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
var directMessageChannels = await GetDiscordClient().GetDirectMessageChannelsAsync(); var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
var channels = directMessageChannels.OrderBy(c => c.Name).ToArray();
foreach (var channel in channels) foreach (var channel in dmChannels.OrderBy(c => c.Name))
console.Output.WriteLine($"{channel.Id} | {channel.Name}"); console.Output.WriteLine($"{channel.Id} | {channel.Name}");
} }
} }

View file

@ -1,232 +0,0 @@
using System;
using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord
{
public partial class DiscordClient
{
private string ParseId(JsonElement json) =>
json.GetProperty("id").GetString();
private User ParseUser(JsonElement json)
{
var id = ParseId(json);
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
var name = json.GetProperty("username").GetString();
var avatarHash = json.GetProperty("avatar").GetString();
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
return new User(id, discriminator, name, avatarHash, isBot);
}
private Member ParseMember(JsonElement json)
{
var userId = json.GetProperty("user").Pipe(ParseId);
var nick = json.GetPropertyOrNull("nick")?.GetString();
var roleIds =
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ??
Array.Empty<string>();
return new Member(userId, nick, roleIds);
}
private Guild ParseGuild(JsonElement json)
{
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<Role>();
return new Guild(id, name, iconHash, roles);
}
private Channel ParseChannel(JsonElement json)
{
var id = ParseId(json);
var parentId = json.GetPropertyOrNull("parent_id")?.GetString();
var type = (ChannelType) json.GetProperty("type").GetInt32();
var topic = json.GetPropertyOrNull("topic")?.GetString();
var guildId =
json.GetPropertyOrNull("guild_id")?.GetString() ??
Guild.DirectMessages.Id;
var name =
json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(ParseUser).Select(u => u.Name).JoinToString(", ") ??
id;
return new Channel(id, guildId, parentId, type, name, topic);
}
private Role ParseRole(JsonElement json)
{
var id = ParseId(json);
var name = json.GetProperty("name").GetString();
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(Color.FromArgb).ResetAlpha().NullIf(c => c.ToRgb() <= 0);
var position = json.GetProperty("position").GetInt32();
return new Role(id, name, color, position);
}
private Attachment ParseAttachment(JsonElement json)
{
var id = ParseId(json);
var url = json.GetProperty("url").GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32();
var fileName = json.GetProperty("filename").GetString();
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
return new Attachment(id, url, fileName, width, height, fileSize);
}
private EmbedAuthor ParseEmbedAuthor(JsonElement json)
{
var name = json.GetPropertyOrNull("name")?.GetString();
var url = json.GetPropertyOrNull("url")?.GetString();
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
return new EmbedAuthor(name, url, iconUrl);
}
private EmbedField ParseEmbedField(JsonElement json)
{
var name = json.GetProperty("name").GetString();
var value = json.GetProperty("value").GetString();
var isInline = json.GetPropertyOrNull("inline")?.GetBoolean() ?? false;
return new EmbedField(name, value, isInline);
}
private EmbedImage ParseEmbedImage(JsonElement json)
{
var url = json.GetPropertyOrNull("url")?.GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32();
return new EmbedImage(url, width, height);
}
private EmbedFooter ParseEmbedFooter(JsonElement json)
{
var text = json.GetProperty("text").GetString();
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
return new EmbedFooter(text, iconUrl);
}
private Embed ParseEmbed(JsonElement json)
{
var title = json.GetPropertyOrNull("title")?.GetString();
var description = json.GetPropertyOrNull("description")?.GetString();
var url = json.GetPropertyOrNull("url")?.GetString();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(Color.FromArgb).ResetAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(ParseEmbedAuthor);
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(ParseEmbedImage);
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<EmbedField>();
return new Embed(
title,
url,
timestamp,
color,
author,
description,
fields,
thumbnail,
image,
footer
);
}
private Emoji ParseEmoji(JsonElement json)
{
var id = json.GetPropertyOrNull("id")?.GetString();
var name = json.GetProperty("name").GetString();
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
return new Emoji(id, name, isAnimated);
}
private Reaction ParseReaction(JsonElement json)
{
var count = json.GetProperty("count").GetInt32();
var emoji = json.GetProperty("emoji").Pipe(ParseEmoji);
return new Reaction(emoji, count);
}
private Message ParseMessage(JsonElement json)
{
var id = ParseId(json);
var channelId = json.GetProperty("channel_id").GetString();
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
var type = (MessageType) json.GetProperty("type").GetInt32();
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
var content = type switch
{
MessageType.RecipientAdd => "Added a recipient.",
MessageType.RecipientRemove => "Removed a recipient.",
MessageType.Call => "Started a call.",
MessageType.ChannelNameChange => "Changed the channel name.",
MessageType.ChannelIconChange => "Changed the channel icon.",
MessageType.ChannelPinnedMessage => "Pinned a message.",
MessageType.GuildMemberJoin => "Joined the server.",
_ => json.GetPropertyOrNull("content")?.GetString() ?? ""
};
var author = json.GetProperty("author").Pipe(ParseUser);
var attachments =
json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(ParseAttachment).ToArray() ??
Array.Empty<Attachment>();
var embeds =
json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(ParseEmbed).ToArray() ??
Array.Empty<Embed>();
var reactions =
json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(ParseReaction).ToArray() ??
Array.Empty<Reaction>();
var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(ParseUser).ToArray() ??
Array.Empty<User>();
return new Message(
id,
channelId,
type,
author,
timestamp,
editedTimestamp,
isPinned,
content,
attachments,
embeds,
reactions,
mentionedUsers
);
}
}
}

View file

@ -68,7 +68,6 @@ namespace DiscordChatExporter.Domain.Discord
return await response.Content.ReadAsJsonAsync(); return await response.Content.ReadAsJsonAsync();
} }
// TODO: do we need this?
private async Task<JsonElement?> TryGetApiResponseAsync(string url) private async Task<JsonElement?> TryGetApiResponseAsync(string url)
{ {
try try
@ -81,36 +80,11 @@ namespace DiscordChatExporter.Domain.Discord
} }
} }
public async Task<Guild> GetGuildAsync(string guildId)
{
// Special case for direct messages pseudo-guild
if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages;
var response = await GetApiResponseAsync($"guilds/{guildId}");
var guild = ParseGuild(response);
return guild;
}
public async Task<Member?> GetGuildMemberAsync(string guildId, string userId)
{
var response = await TryGetApiResponseAsync($"guilds/{guildId}/members/{userId}");
return response?.Pipe(ParseMember);
}
public async Task<Channel> GetChannelAsync(string channelId)
{
var response = await GetApiResponseAsync($"channels/{channelId}");
var channel = ParseChannel(response);
return channel;
}
public async IAsyncEnumerable<Guild> GetUserGuildsAsync() public async IAsyncEnumerable<Guild> GetUserGuildsAsync()
{ {
var afterId = ""; yield return Guild.DirectMessages;
var afterId = "";
while (true) while (true)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
@ -122,15 +96,12 @@ namespace DiscordChatExporter.Domain.Discord
var response = await GetApiResponseAsync(url); var response = await GetApiResponseAsync(url);
var isEmpty = true; var isEmpty = true;
// Get full guild object
foreach (var guildJson in response.EnumerateArray()) foreach (var guildJson in response.EnumerateArray())
{ {
var guildId = ParseId(guildJson); var guild = Guild.Parse(guildJson);
yield return guild;
yield return await GetGuildAsync(guildId);
afterId = guildId;
afterId = guild.Id;
isEmpty = false; isEmpty = false;
} }
@ -139,27 +110,93 @@ namespace DiscordChatExporter.Domain.Discord
} }
} }
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync() public async Task<Guild> GetGuildAsync(string guildId)
{ {
var response = await GetApiResponseAsync("users/@me/channels");
var channels = response.EnumerateArray().Select(ParseChannel).ToArray();
return channels;
}
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string guildId)
{
// Direct messages pseudo-guild
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
return Array.Empty<Channel>(); return Guild.DirectMessages;
var response = await GetApiResponseAsync($"guilds/{guildId}/channels"); var response = await GetApiResponseAsync($"guilds/{guildId}");
var channels = response.EnumerateArray().Select(ParseChannel).ToArray(); return Guild.Parse(response);
return channels;
} }
private async Task<Message> GetLastMessageAsync(string channelId, DateTimeOffset? before = null) public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(string guildId)
{
if (guildId == Guild.DirectMessages.Id)
{
var response = await GetApiResponseAsync("users/@me/channels");
foreach (var channelJson in response.EnumerateArray())
yield return Channel.Parse(channelJson);
}
else
{
var response = await GetApiResponseAsync($"guilds/{guildId}/channels");
var categories = response
.EnumerateArray()
.ToDictionary(
j => j.GetProperty("id").GetString(),
j => j.GetProperty("name").GetString()
);
foreach (var channelJson in response.EnumerateArray())
{
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString();
var category = !string.IsNullOrWhiteSpace(parentId)
? categories.GetValueOrDefault(parentId)
: null;
var channel = Channel.Parse(channelJson, category);
// Skip non-text channels
if (!channel.IsTextChannel)
continue;
yield return channel;
}
}
}
public async IAsyncEnumerable<Role> GetGuildRolesAsync(string guildId)
{
if (guildId == Guild.DirectMessages.Id)
yield break;
var response = await GetApiResponseAsync($"guilds/{guildId}/roles");
foreach (var roleJson in response.EnumerateArray())
{
yield return Role.Parse(roleJson);
}
}
public async Task<Member?> TryGetGuildMemberAsync(string guildId, User user)
{
if (guildId == Guild.DirectMessages.Id)
return Member.CreateForUser(user);
var response = await TryGetApiResponseAsync($"guilds/{guildId}/members/{user.Id}");
return response?.Pipe(Member.Parse);
}
private async Task<string> GetChannelCategoryAsync(string channelParentId)
{
var response = await GetApiResponseAsync($"channels/{channelParentId}");
return response.GetProperty("name").GetString();
}
public async Task<Channel> GetChannelAsync(string channelId)
{
var response = await GetApiResponseAsync($"channels/{channelId}");
var parentId = response.GetPropertyOrNull("parent_id")?.GetString();
var category = !string.IsNullOrWhiteSpace(parentId)
? await GetChannelCategoryAsync(parentId)
: null;
return Channel.Parse(response, category);
}
private async Task<Message?> TryGetLastMessageAsync(string channelId, DateTimeOffset? before = null)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages") .SetPath($"channels/{channelId}/messages")
@ -168,8 +205,7 @@ namespace DiscordChatExporter.Domain.Discord
.Build(); .Build();
var response = await GetApiResponseAsync(url); var response = await GetApiResponseAsync(url);
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
return response.EnumerateArray().Select(ParseMessage).FirstOrDefault();
} }
public async IAsyncEnumerable<Message> GetMessagesAsync( public async IAsyncEnumerable<Message> GetMessagesAsync(
@ -178,9 +214,8 @@ namespace DiscordChatExporter.Domain.Discord
DateTimeOffset? before = null, DateTimeOffset? before = null,
IProgress<double>? progress = null) IProgress<double>? progress = null)
{ {
var lastMessage = await GetLastMessageAsync(channelId, before); // Get the last message in the specified range
var lastMessage = await TryGetLastMessageAsync(channelId, before);
// If the last message doesn't exist or it's outside of range - return
if (lastMessage == null || lastMessage.Timestamp < after) if (lastMessage == null || lastMessage.Timestamp < after)
yield break; yield break;
@ -199,13 +234,13 @@ namespace DiscordChatExporter.Domain.Discord
var messages = response var messages = response
.EnumerateArray() .EnumerateArray()
.Select(ParseMessage) .Select(Message.Parse)
.Reverse() // reverse because messages appear newest first .Reverse() // reverse because messages appear newest first
.ToArray(); .ToArray();
// Break if there are no messages (can happen if messages are deleted during execution) // Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any()) if (!messages.Any())
break; yield break;
foreach (var message in messages) foreach (var message in messages)
{ {
@ -223,10 +258,6 @@ namespace DiscordChatExporter.Domain.Discord
yield return message; yield return message;
afterId = message.Id; afterId = message.Id;
// Yielded last message - break loop
if (message.Id == lastMessage.Id)
yield break;
} }
} }
} }

View file

@ -1,12 +1,13 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#attachment-object // https://discordapp.com/developers/docs/resources/channel#attachment-object
public partial class Attachment : IHasId public partial class Attachment : IHasId
{ {
public string Id { get; } public string Id { get; }
@ -19,9 +20,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
public int? Height { get; } public int? Height { get; }
public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase); public bool IsImage =>
ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
public bool IsSpoiler => IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal); public bool IsSpoiler =>
IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
public FileSize FileSize { get; } public FileSize FileSize { get; }
@ -41,5 +44,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Attachment public partial class Attachment
{ {
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"}; private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
public static Attachment Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var url = json.GetProperty("url").GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32();
var fileName = json.GetProperty("filename").GetString();
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
return new Attachment(id, url, fileName, width, height, fileSize);
}
} }
} }

View file

@ -1,10 +1,13 @@
using DiscordChatExporter.Domain.Discord.Models.Common; using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types // https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
// Order of enum fields needs to match the order in the docs. // Order of enum fields needs to match the order in the docs.
public enum ChannelType public enum ChannelType
{ {
GuildTextChat, GuildTextChat,
@ -17,15 +20,10 @@ namespace DiscordChatExporter.Domain.Discord.Models
} }
// https://discordapp.com/developers/docs/resources/channel#channel-object // https://discordapp.com/developers/docs/resources/channel#channel-object
public partial class Channel : IHasId public partial class Channel : IHasId
{ {
public string Id { get; } public string Id { get; }
public string GuildId { get; }
public string? ParentId { get; }
public ChannelType Type { get; } public ChannelType Type { get; }
public bool IsTextChannel => public bool IsTextChannel =>
@ -35,16 +33,20 @@ namespace DiscordChatExporter.Domain.Discord.Models
Type == ChannelType.GuildNews || Type == ChannelType.GuildNews ||
Type == ChannelType.GuildStore; Type == ChannelType.GuildStore;
public string GuildId { get; }
public string Category { get; }
public string Name { get; } public string Name { get; }
public string? Topic { get; } public string? Topic { get; }
public Channel(string id, string guildId, string? parentId, ChannelType type, string name, string? topic) public Channel(string id, ChannelType type, string guildId, string category, string name, string? topic)
{ {
Id = id; Id = id;
GuildId = guildId;
ParentId = parentId;
Type = type; Type = type;
GuildId = guildId;
Category = category;
Name = name; Name = name;
Topic = topic; Topic = topic;
} }
@ -54,7 +56,36 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Channel public partial class Channel
{ {
public static Channel CreateDeletedChannel(string id) => private static string GetDefaultCategory(ChannelType channelType) => channelType switch
new Channel(id, "unknown-guild", null, ChannelType.GuildTextChat, "deleted-channel", null); {
ChannelType.GuildTextChat => "Text",
ChannelType.DirectTextChat => "Private",
ChannelType.DirectGroupTextChat => "Group",
ChannelType.GuildNews => "News",
ChannelType.GuildStore => "Store",
_ => "Default"
};
public static Channel Parse(JsonElement json, string? category = null)
{
var id = json.GetProperty("id").GetString();
var guildId = json.GetPropertyOrNull("guild_id")?.GetString();
var topic = json.GetPropertyOrNull("topic")?.GetString();
var type = (ChannelType) json.GetProperty("type").GetInt32();
var name =
json.GetPropertyOrNull("name")?.GetString() ??
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
id;
return new Channel(
id,
type,
guildId ?? Guild.DirectMessages.Id,
category ?? GetDefaultCategory(type),
name,
topic);
}
} }
} }

View file

@ -3,7 +3,6 @@
namespace DiscordChatExporter.Domain.Discord.Models.Common namespace DiscordChatExporter.Domain.Discord.Models.Common
{ {
// Loosely based on https://github.com/omar/ByteSize (MIT license) // Loosely based on https://github.com/omar/ByteSize (MIT license)
public readonly partial struct FileSize public readonly partial struct FileSize
{ {
public long TotalBytes { get; } public long TotalBytes { get; }

View file

@ -1,12 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing; using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#embed-object // https://discordapp.com/developers/docs/resources/channel#embed-object
public partial class Embed
public class Embed
{ {
public string? Title { get; } public string? Title { get; }
@ -54,4 +56,38 @@ namespace DiscordChatExporter.Domain.Discord.Models
public override string ToString() => Title ?? "<untitled embed>"; public override string ToString() => Title ?? "<untitled embed>";
} }
public partial class Embed
{
public static Embed Parse(JsonElement json)
{
var title = json.GetPropertyOrNull("title")?.GetString();
var description = json.GetPropertyOrNull("description")?.GetString();
var url = json.GetPropertyOrNull("url")?.GetString();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
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 fields =
json.GetPropertyOrNull("fields")?.EnumerateArray().Select(EmbedField.Parse).ToArray() ??
Array.Empty<EmbedField>();
return new Embed(
title,
url,
timestamp,
color,
author,
description,
fields,
thumbnail,
image,
footer
);
}
}
} }

View file

@ -1,8 +1,10 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
public partial class EmbedAuthor
public class EmbedAuthor
{ {
public string? Name { get; } public string? Name { get; }
@ -19,4 +21,16 @@ namespace DiscordChatExporter.Domain.Discord.Models
public override string ToString() => Name ?? "<unnamed author>"; public override string ToString() => Name ?? "<unnamed author>";
} }
public partial class EmbedAuthor
{
public static EmbedAuthor Parse(JsonElement json)
{
var name = json.GetPropertyOrNull("name")?.GetString();
var url = json.GetPropertyOrNull("url")?.GetString();
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
return new EmbedAuthor(name, url, iconUrl);
}
}
} }

View file

@ -1,8 +1,10 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
public partial class EmbedField
public class EmbedField
{ {
public string Name { get; } public string Name { get; }
@ -19,4 +21,16 @@ namespace DiscordChatExporter.Domain.Discord.Models
public override string ToString() => $"{Name} | {Value}"; public override string ToString() => $"{Name} | {Value}";
} }
public partial class EmbedField
{
public static EmbedField Parse(JsonElement json)
{
var name = json.GetProperty("name").GetString();
var value = json.GetProperty("value").GetString();
var isInline = json.GetPropertyOrNull("inline")?.GetBoolean() ?? false;
return new EmbedField(name, value, isInline);
}
}
} }

View file

@ -1,8 +1,10 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
public partial class EmbedFooter
public class EmbedFooter
{ {
public string Text { get; } public string Text { get; }
@ -16,4 +18,15 @@ namespace DiscordChatExporter.Domain.Discord.Models
public override string ToString() => Text; public override string ToString() => Text;
} }
public partial class EmbedFooter
{
public static EmbedFooter Parse(JsonElement json)
{
var text = json.GetProperty("text").GetString();
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
return new EmbedFooter(text, iconUrl);
}
}
} }

View file

@ -1,8 +1,10 @@
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
public partial class EmbedImage
public class EmbedImage
{ {
public string? Url { get; } public string? Url { get; }
@ -17,4 +19,16 @@ namespace DiscordChatExporter.Domain.Discord.Models
Width = width; Width = width;
} }
} }
public partial class EmbedImage
{
public static EmbedImage Parse(JsonElement json)
{
var url = json.GetPropertyOrNull("url")?.GetString();
var width = json.GetPropertyOrNull("width")?.GetInt32();
var height = json.GetPropertyOrNull("height")?.GetInt32();
return new EmbedImage(url, width, height);
}
}
} }

View file

@ -1,12 +1,13 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/emoji#emoji-object // https://discordapp.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji public partial class Emoji
{ {
public string? Id { get; } public string? Id { get; }
@ -60,12 +61,19 @@ namespace DiscordChatExporter.Domain.Discord.Models
return $"https://cdn.discordapp.com/emojis/{id}.png"; return $"https://cdn.discordapp.com/emojis/{id}.png";
} }
// Get runes // Standard emoji
var emojiRunes = GetRunes(name).ToArray(); var emojiRunes = GetRunes(name).ToArray();
// Get corresponding Twemoji image
var twemojiName = GetTwemojiName(emojiRunes); var twemojiName = GetTwemojiName(emojiRunes);
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png"; return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
} }
public static Emoji Parse(JsonElement json)
{
var id = json.GetPropertyOrNull("id")?.GetString();
var name = json.GetProperty("name").GetString();
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
return new Emoji(id, name, isAnimated);
}
} }
} }

View file

@ -1,36 +1,23 @@
using System; using System.Text.Json;
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/guild#guild-object // https://discordapp.com/developers/docs/resources/guild#guild-object
public partial class Guild : IHasId public partial class Guild : IHasId
{ {
public string Id { get; } public string Id { get; }
public string Name { get; } public string Name { get; }
public string? IconHash { get; } public string IconUrl { get; }
public string IconUrl => !string.IsNullOrWhiteSpace(IconHash) public Guild(string id, string name, string? iconHash)
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
: "https://cdn.discordapp.com/embed/avatars/0.png";
public IReadOnlyList<Role> Roles { get; }
public Dictionary<string, Member?> Members { get; }
public Guild(string id, string name, string? iconHash, IReadOnlyList<Role> roles)
{ {
Id = id; Id = id;
Name = name; Name = name;
IconHash = iconHash;
Roles = roles; IconUrl = GetIconUrl(id, iconHash);
Members = new Dictionary<string, Member?>();
} }
public override string ToString() => Name; public override string ToString() => Name;
@ -38,18 +25,21 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Guild public partial class Guild
{ {
public static string GetUserColor(Guild guild, User user) => private static string GetIconUrl(string? id, string? iconHash) =>
guild.Members.GetValueOrDefault(user.Id, null)? !string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(iconHash)
.RoleIds ? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
.Select(r => guild.Roles.FirstOrDefault(role => r == role.Id)) : "https://cdn.discordapp.com/embed/avatars/0.png";
.Where(r => r != null)
.Where(r => r.Color != null)
.Aggregate<Role, Role?>(null, (a, b) => (a?.Position ?? 0) > b.Position ? a : b)?
.Color?
.ToHexString() ?? "";
public static string GetUserNick(Guild guild, User user) => guild.Members.GetValueOrDefault(user.Id)?.Nick ?? user.Name; public static Guild DirectMessages { get; } =
new Guild("@me", "Direct Messages", null);
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, Array.Empty<Role>()); public static Guild Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var name = json.GetProperty("name").GetString();
var iconHash = json.GetProperty("icon").GetString();
return new Guild(id, name, iconHash);
}
} }
} }

View file

@ -1,22 +1,52 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/guild#guild-member-object // https://discordapp.com/developers/docs/resources/guild#guild-member-object
public partial class Member : IHasId
public class Member
{ {
public string UserId { get; } public string Id => User.Id;
public string? Nick { get; } public User User { get; }
public string Nick { get; }
public IReadOnlyList<string> RoleIds { get; } public IReadOnlyList<string> RoleIds { get; }
public Member(string userId, string? nick, IReadOnlyList<string> roleIds) public Member(User user, string? nick, IReadOnlyList<string> roleIds)
{ {
UserId = userId; User = user;
Nick = nick; Nick = nick ?? user.Name;
RoleIds = roleIds; RoleIds = roleIds;
} }
public override string ToString() => Nick;
}
public partial class Member
{
public static Member CreateForUser(User user) =>
new Member(user, null, Array.Empty<string>());
public static Member Parse(JsonElement json)
{
var user = json.GetProperty("user").Pipe(User.Parse);
var nick = json.GetPropertyOrNull("nick")?.GetString();
var roleIds =
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).ToArray() ??
Array.Empty<string>();
return new Member(
user,
nick,
roleIds
);
}
} }
} }

View file

@ -1,12 +1,13 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types // https://discordapp.com/developers/docs/resources/channel#message-object-message-types
public enum MessageType public enum MessageType
{ {
Default, Default,
@ -20,13 +21,10 @@ namespace DiscordChatExporter.Domain.Discord.Models
} }
// https://discordapp.com/developers/docs/resources/channel#message-object // https://discordapp.com/developers/docs/resources/channel#message-object
public partial class Message : IHasId
public class Message : IHasId
{ {
public string Id { get; } public string Id { get; }
public string ChannelId { get; }
public MessageType Type { get; } public MessageType Type { get; }
public User Author { get; } public User Author { get; }
@ -49,7 +47,6 @@ namespace DiscordChatExporter.Domain.Discord.Models
public Message( public Message(
string id, string id,
string channelId,
MessageType type, MessageType type,
User author, User author,
DateTimeOffset timestamp, DateTimeOffset timestamp,
@ -62,7 +59,6 @@ namespace DiscordChatExporter.Domain.Discord.Models
IReadOnlyList<User> mentionedUsers) IReadOnlyList<User> mentionedUsers)
{ {
Id = id; Id = id;
ChannelId = channelId;
Type = type; Type = type;
Author = author; Author = author;
Timestamp = timestamp; Timestamp = timestamp;
@ -80,4 +76,59 @@ namespace DiscordChatExporter.Domain.Discord.Models
? "<embed>" ? "<embed>"
: "<no content>"); : "<no content>");
} }
public partial class Message
{
public static Message Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var author = json.GetProperty("author").Pipe(User.Parse);
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
var type = (MessageType) json.GetProperty("type").GetInt32();
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
var content = type switch
{
MessageType.RecipientAdd => "Added a recipient.",
MessageType.RecipientRemove => "Removed a recipient.",
MessageType.Call => "Started a call.",
MessageType.ChannelNameChange => "Changed the channel name.",
MessageType.ChannelIconChange => "Changed the channel icon.",
MessageType.ChannelPinnedMessage => "Pinned a message.",
MessageType.GuildMemberJoin => "Joined the server.",
_ => json.GetPropertyOrNull("content")?.GetString() ?? ""
};
var attachments =
json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(Attachment.Parse).ToArray() ??
Array.Empty<Attachment>();
var embeds =
json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(Embed.Parse).ToArray() ??
Array.Empty<Embed>();
var reactions =
json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(Reaction.Parse).ToArray() ??
Array.Empty<Reaction>();
var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(User.Parse).ToArray() ??
Array.Empty<User>();
return new Message(
id,
type,
author,
timestamp,
editedTimestamp,
isPinned,
content,
attachments,
embeds,
reactions,
mentionedUsers
);
}
}
} }

View file

@ -1,8 +1,10 @@
namespace DiscordChatExporter.Domain.Discord.Models using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/channel#reaction-object // https://discordapp.com/developers/docs/resources/channel#reaction-object
public partial class Reaction
public class Reaction
{ {
public Emoji Emoji { get; } public Emoji Emoji { get; }
@ -16,4 +18,15 @@
public override string ToString() => $"{Emoji} ({Count})"; public override string ToString() => $"{Emoji} ({Count})";
} }
public partial class Reaction
{
public static Reaction Parse(JsonElement json)
{
var count = json.GetProperty("count").GetInt32();
var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse);
return new Reaction(emoji, count);
}
}
} }

View file

@ -1,26 +1,26 @@
using System.Drawing; using System.Drawing;
using DiscordChatExporter.Domain.Discord.Models.Common; using System.Text.Json;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/topics/permissions#role-object // https://discordapp.com/developers/docs/topics/permissions#role-object
public partial class Role
public partial class Role : IHasId
{ {
public string Id { get; } public string Id { get; }
public string Name { get; } public string Name { get; }
public Color? Color { get; }
public int Position { get; } public int Position { get; }
public Role(string id, string name, Color? color, int position) public Color? Color { get; }
public Role(string id, string name, int position, Color? color)
{ {
Id = id; Id = id;
Name = name; Name = name;
Color = color;
Position = position; Position = position;
Color = color;
} }
public override string ToString() => Name; public override string ToString() => Name;
@ -28,6 +28,19 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Role public partial class Role
{ {
public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role", null, -1); public static Role Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var name = json.GetProperty("name").GetString();
var position = json.GetProperty("position").GetInt32();
var color = json.GetPropertyOrNull("color")?
.GetInt32()
.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha()
.NullIf(c => c.ToRgb() <= 0);
return new Role(id, name, position, color);
}
} }
} }

View file

@ -1,33 +1,31 @@
using System; using System;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Discord.Models namespace DiscordChatExporter.Domain.Discord.Models
{ {
// https://discordapp.com/developers/docs/resources/user#user-object // https://discordapp.com/developers/docs/resources/user#user-object
public partial class User : IHasId public partial class User : IHasId
{ {
public string Id { get; } public string Id { get; }
public bool IsBot { get; }
public int Discriminator { get; } public int Discriminator { get; }
public string Name { get; } public string Name { get; }
public string FullName => $"{Name}#{Discriminator:0000}"; public string FullName => $"{Name}#{Discriminator:0000}";
public string? AvatarHash { get; }
public string AvatarUrl { get; } public string AvatarUrl { get; }
public bool IsBot { get; } public User(string id, bool isBot, int discriminator, string name, string? avatarHash)
public User(string id, int discriminator, string name, string? avatarHash, bool isBot)
{ {
Id = id; Id = id;
IsBot = isBot;
Discriminator = discriminator; Discriminator = discriminator;
Name = name; Name = name;
AvatarHash = avatarHash;
IsBot = isBot;
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash); AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
} }
@ -54,6 +52,15 @@ namespace DiscordChatExporter.Domain.Discord.Models
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
} }
public static User CreateUnknownUser(string id) => new User(id, 0, "Unknown", null, false); public static User Parse(JsonElement json)
{
var id = json.GetProperty("id").GetString();
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
var name = json.GetProperty("username").GetString();
var avatarHash = json.GetProperty("avatar").GetString();
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
return new User(id, isBot, discriminator, name, avatarHash);
}
} }
} }

View file

@ -40,9 +40,7 @@ namespace DiscordChatExporter.Domain.Discord
buffer.Append(_path); buffer.Append(_path);
if (_queryParameters.Any()) if (_queryParameters.Any())
buffer.Append('?'); buffer.Append('?').AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
buffer.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
return buffer.ToString(); return buffer.ToString();
} }

View file

@ -1,13 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord; using DiscordChatExporter.Domain.Discord;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Exceptions; using DiscordChatExporter.Domain.Exceptions;
using Tyrrrz.Extensions; using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Domain.Exporting namespace DiscordChatExporter.Domain.Exporting
{ {
@ -36,37 +37,35 @@ namespace DiscordChatExporter.Domain.Exporting
var options = new ExportOptions(baseFilePath, format, partitionLimit); var options = new ExportOptions(baseFilePath, format, partitionLimit);
// Context // Context
var mentionableUsers = new HashSet<User>(IdBasedEqualityComparer.Instance); var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
var mentionableChannels = await _discord.GetGuildChannelsAsync(guild.Id); var contextChannels = await _discord.GetGuildChannelsAsync(guild.Id);
var mentionableRoles = guild.Roles; var contextRoles = await _discord.GetGuildRolesAsync(guild.Id);
var context = new ExportContext( var context = new ExportContext(
guild, channel, after, before, dateFormat, guild, channel, after, before, dateFormat,
mentionableUsers, mentionableChannels, mentionableRoles contextMembers, contextChannels, contextRoles
); );
await using var messageExporter = new MessageExporter(options, context); await using var messageExporter = new MessageExporter(options, context);
var exportedAnything = false; var exportedAnything = false;
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress)) await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress))
{ {
// Add encountered users to the list of mentionable users // Resolve members for referenced users
var encounteredUsers = new List<User>(); foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
encounteredUsers.Add(message.Author);
encounteredUsers.AddRange(message.MentionedUsers);
mentionableUsers.AddRange(encounteredUsers);
foreach (User u in encounteredUsers)
{ {
if (!guild.Members.ContainsKey(u.Id)) if (encounteredUsers.Add(referencedUser))
{ {
var member = await _discord.GetGuildMemberAsync(guild.Id, u.Id); var member =
guild.Members[u.Id] = member; await _discord.TryGetGuildMemberAsync(guild.Id, referencedUser) ??
Member.CreateForUser(referencedUser);
contextMembers.Add(member);
} }
} }
// Render message // Export message
await messageExporter.ExportMessageAsync(message); await messageExporter.ExportMessageAsync(message);
exportedAnything = true; exportedAnything = true;
} }

View file

@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
namespace DiscordChatExporter.Domain.Exporting namespace DiscordChatExporter.Domain.Exporting
@ -16,11 +18,11 @@ namespace DiscordChatExporter.Domain.Exporting
public string DateFormat { get; } public string DateFormat { get; }
public IReadOnlyCollection<User> MentionableUsers { get; } public IReadOnlyCollection<Member> Members { get; }
public IReadOnlyCollection<Channel> MentionableChannels { get; } public IReadOnlyCollection<Channel> Channels { get; }
public IReadOnlyCollection<Role> MentionableRoles { get; } public IReadOnlyCollection<Role> Roles { get; }
public ExportContext( public ExportContext(
Guild guild, Guild guild,
@ -28,19 +30,41 @@ namespace DiscordChatExporter.Domain.Exporting
DateTimeOffset? after, DateTimeOffset? after,
DateTimeOffset? before, DateTimeOffset? before,
string dateFormat, string dateFormat,
IReadOnlyCollection<User> mentionableUsers, IReadOnlyCollection<Member> members,
IReadOnlyCollection<Channel> mentionableChannels, IReadOnlyCollection<Channel> channels,
IReadOnlyCollection<Role> mentionableRoles) IReadOnlyCollection<Role> roles)
{ {
Guild = guild; Guild = guild;
Channel = channel; Channel = channel;
After = after; After = after;
Before = before; Before = before;
DateFormat = dateFormat; DateFormat = dateFormat;
MentionableUsers = mentionableUsers; Members = members;
MentionableChannels = mentionableChannels; Channels = channels;
MentionableRoles = mentionableRoles; Roles = roles;
}
public Member? TryGetMentionedMember(string id) =>
Members.FirstOrDefault(m => m.Id == id);
public Channel? TryGetMentionedChannel(string id) =>
Channels.FirstOrDefault(c => c.Id == id);
public Role? TryGetMentionedRole(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 roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
return roles?
.OrderByDescending(r => r.Position)
.Select(r => r.Color)
.FirstOrDefault(c => c != null);
} }
} }
} }

View file

@ -48,7 +48,7 @@ img {
} }
.markdown { .markdown {
display: inline-block; max-width: 100%;
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.3; line-height: 1.3;
overflow-wrap: break-word; overflow-wrap: break-word;

View file

@ -62,22 +62,22 @@
</div> </div>
<div class="preamble__entries-container"> <div class="preamble__entries-container">
<div class="preamble__entry">{{ Context.Guild.Name | html.escape }}</div> <div class="preamble__entry">{{ Context.Guild.Name | html.escape }}</div>
<div class="preamble__entry">{{ Context.Channel.Name | html.escape }}</div> <div class="preamble__entry">{{ Context.Channel.Category | html.escape }} / {{ Context.Channel.Name | html.escape }}</div>
{{~ if Context.Channel.Topic ~}} {{~ if Context.Channel.Topic ~}}
<div class="preamble__entry preamble__entry--small">{{ Context.Channel.Topic | html.escape }}</div> <div class="preamble__entry preamble__entry--small">{{ Context.Channel.Topic | html.escape }}</div>
{{~ end ~}} {{~ end ~}}
{{~ if Context.After || Context.Before ~}} {{~ if Context.After || Context.Before ~}}
<div class="preamble__entry preamble__entry--small"> <div class="preamble__entry preamble__entry--small">
{{~ if Context.After && Context.Before ~}} {{~ if Context.After && Context.Before ~}}
Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }} Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }}
{{~ else if Context.After ~}} {{~ else if Context.After ~}}
After {{ Context.After | FormatDate | html.escape }} After {{ Context.After | FormatDate | html.escape }}
{{~ else if Context.Before ~}} {{~ else if Context.Before ~}}
Before {{ Context.Before | FormatDate | html.escape }} Before {{ Context.Before | FormatDate | html.escape }}
{{~ end ~}} {{~ end ~}}
</div> </div>
{{~ end ~}} {{~ end ~}}
</div> </div>
</div> </div>

View file

@ -5,27 +5,30 @@
</div> </div>
<div class="chatlog__messages"> <div class="chatlog__messages">
{{~ # Author name and timestamp ~}} {{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if GetUserColor Context.Guild MessageGroup.Author }} style="color: {{ GetUserColor Context.Guild MessageGroup.Author }}" {{ end }}>{{ GetUserNick Context.Guild MessageGroup.Author | html.escape }}</span> {{~ userColor = TryGetUserColor MessageGroup.Author | FormatColorRgb ~}}
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if userColor }} style="color: {{ userColor }}" {{ end }}>{{ (TryGetUserNick MessageGroup.Author ?? MessageGroup.Author.Name) | html.escape }}</span>
{{~ # Bot tag ~}} {{~ # Bot tag ~}}
{{~ if MessageGroup.Author.IsBot ~}} {{~ if MessageGroup.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span> <span class="chatlog__bot-tag">BOT</span>
{{~ end ~}} {{~ end ~}}
<span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span> <span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}} {{~ # Messages ~}}
{{~ for message in MessageGroup.Messages ~}} {{~ for message in MessageGroup.Messages ~}}
<div class="chatlog__message {{if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}"> <div class="chatlog__message {{ if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
{{~ # Content ~}} {{~ # Content ~}}
{{~ if message.Content ~}} {{~ if message.Content ~}}
<div class="chatlog__content"> <div class="chatlog__content">
<div class="markdown">{{ message.Content | FormatMarkdown }}</div> <div class="markdown">
{{- message.Content | FormatMarkdown -}}
{{~ # Edited timestamp ~}} {{- # Edited timestamp -}}
{{~ if message.EditedTimestamp ~}} {{- if message.EditedTimestamp -}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span> {{-}}<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>{{-}}
{{~ end ~}} {{- end -}}
</div>
</div> </div>
{{~ end ~}} {{~ end ~}}
@ -89,16 +92,16 @@
{{~ if embed.Title ~}} {{~ if embed.Title ~}}
<div class="chatlog__embed-title"> <div class="chatlog__embed-title">
{{~ if embed.Url ~}} {{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a> <a class="chatlog__embed-title-link" href="{{ embed.Url }}"><div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div></a>
{{~ else ~}} {{~ else ~}}
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span> <div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div>
{{~ end ~}} {{~ end ~}}
</div> </div>
{{~ end ~}} {{~ end ~}}
{{~ # Description ~}} {{~ # Description ~}}
{{~ if embed.Description ~}} {{~ if embed.Description ~}}
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div> <div class="chatlog__embed-description"><div class="markdown">{{ embed.Description | FormatEmbedMarkdown }}</div></div>
{{~ end ~}} {{~ end ~}}
{{~ # Fields ~}} {{~ # Fields ~}}
@ -107,10 +110,10 @@
{{~ for field in embed.Fields ~}} {{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}"> <div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}} {{~ if field.Name ~}}
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div> <div class="chatlog__embed-field-name"><div class="markdown">{{ field.Name | FormatEmbedMarkdown }}</div></div>
{{~ end ~}} {{~ end ~}}
{{~ if field.Value ~}} {{~ if field.Value ~}}
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div> <div class="chatlog__embed-field-value"><div class="markdown">{{ field.Value | FormatEmbedMarkdown }}</div></div>
{{~ end ~}} {{~ end ~}}
</div> </div>
{{~ end ~}} {{~ end ~}}

View file

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Drawing;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection; using System.Reflection;
@ -73,12 +74,20 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
scriptObject.Import("FormatDate", scriptObject.Import("FormatDate",
new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.DateFormat))); new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.DateFormat)));
scriptObject.Import("FormatColorRgb",
new Func<Color?, string?>(c => c != null ? $"rgb({c?.R}, {c?.G}, {c?.B})" : null));
scriptObject.Import("TryGetUserColor",
new Func<User, Color?>(Context.TryGetUserColor));
scriptObject.Import("TryGetUserNick",
new Func<User, string?>(u => Context.TryGetUserMember(u)?.Nick));
scriptObject.Import("FormatMarkdown", scriptObject.Import("FormatMarkdown",
new Func<string?, string>(FormatMarkdown)); new Func<string?, string>(m => FormatMarkdown(m)));
scriptObject.Import("GetUserColor", new Func<Guild, User, string>(Guild.GetUserColor)); scriptObject.Import("FormatEmbedMarkdown",
new Func<string?, string>(m => FormatMarkdown(m, false)));
scriptObject.Import("GetUserNick", new Func<Guild, User, string>(Guild.GetUserNick));
// Push model // Push model
templateContext.PushGlobal(scriptObject); templateContext.PushGlobal(scriptObject);
@ -89,8 +98,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
return templateContext; return templateContext;
} }
private string FormatMarkdown(string? markdown) => private string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(Context, markdown ?? ""); HtmlMarkdownVisitor.Format(Context, markdown ?? "", isJumboAllowed);
private async Task RenderCurrentMessageGroupAsync() private async Task RenderCurrentMessageGroupAsync()
{ {

View file

@ -84,8 +84,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("name", embedField.Name); _writer.WriteString("name", FormatMarkdown(embedField.Name));
_writer.WriteString("value", embedField.Value); _writer.WriteString("value", FormatMarkdown(embedField.Value));
_writer.WriteBoolean("isInline", embedField.IsInline); _writer.WriteBoolean("isInline", embedField.IsInline);
_writer.WriteEndObject(); _writer.WriteEndObject();
@ -156,6 +156,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteStartObject("channel"); _writer.WriteStartObject("channel");
_writer.WriteString("id", Context.Channel.Id); _writer.WriteString("id", Context.Channel.Id);
_writer.WriteString("type", Context.Channel.Type.ToString()); _writer.WriteString("type", Context.Channel.Type.ToString());
_writer.WriteString("category", Context.Channel.Category);
_writer.WriteString("name", Context.Channel.Name); _writer.WriteString("name", Context.Channel.Name);
_writer.WriteString("topic", Context.Channel.Topic); _writer.WriteString("topic", Context.Channel.Topic);
_writer.WriteEndObject(); _writer.WriteEndObject();

View file

@ -85,38 +85,38 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
} }
else if (mention.Type == MentionType.User) else if (mention.Type == MentionType.User)
{ {
var user = _context.MentionableUsers.FirstOrDefault(u => u.Id == mention.Id) ?? var member = _context.TryGetMentionedMember(mention.Id);
User.CreateUnknownUser(mention.Id); var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown";
var nick = Guild.GetUserNick(_context.Guild, user);
_buffer _buffer
.Append($"<span class=\"mention\" title=\"{HtmlEncode(user.FullName)}\">") .Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
.Append("@").Append(HtmlEncode(nick)) .Append("@").Append(HtmlEncode(nick))
.Append("</span>"); .Append("</span>");
} }
else if (mention.Type == MentionType.Channel) else if (mention.Type == MentionType.Channel)
{ {
var channel = _context.MentionableChannels.FirstOrDefault(c => c.Id == mention.Id) ?? var channel = _context.TryGetMentionedChannel(mention.Id);
Channel.CreateDeletedChannel(mention.Id); var name = channel?.Name ?? "deleted-channel";
_buffer _buffer
.Append("<span class=\"mention\">") .Append("<span class=\"mention\">")
.Append("#").Append(HtmlEncode(channel.Name)) .Append("#").Append(HtmlEncode(name))
.Append("</span>"); .Append("</span>");
} }
else if (mention.Type == MentionType.Role) else if (mention.Type == MentionType.Role)
{ {
var role = _context.MentionableRoles.FirstOrDefault(r => r.Id == mention.Id) ?? var role = _context.TryGetMentionedRole(mention.Id);
Role.CreateDeletedRole(mention.Id); var name = role?.Name ?? "deleted-role";
var color = role?.Color;
var style = role.Color != null var style = color != null
? $"color: {role.Color.Value.ToHexString()}; background-color: rgba({role.Color.Value.ToRgbString()}, 0.1);" ? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
: ""; : "";
_buffer _buffer
.Append($"<span class=\"mention\" style=\"{style}\">") .Append($"<span class=\"mention\" style=\"{style}\">")
.Append("@").Append(HtmlEncode(role.Name)) .Append("@").Append(HtmlEncode(name))
.Append("</span>"); .Append("</span>");
} }
@ -162,10 +162,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
{ {
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text); private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
public static string Format(ExportContext context, string markdown) public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true)
{ {
var nodes = MarkdownParser.Parse(markdown); var nodes = MarkdownParser.Parse(markdown);
var isJumbo = nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
var isJumbo =
isJumboAllowed &&
nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
var buffer = new StringBuilder(); var buffer = new StringBuilder();

View file

@ -1,6 +1,4 @@
using System.Linq; using System.Text;
using System.Text;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Markdown; using DiscordChatExporter.Domain.Markdown;
using DiscordChatExporter.Domain.Markdown.Ast; using DiscordChatExporter.Domain.Markdown.Ast;
@ -27,24 +25,24 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
{ {
if (mention.Type == MentionType.User) if (mention.Type == MentionType.User)
{ {
var user = _context.MentionableUsers.FirstOrDefault(u => u.Id == mention.Id) ?? var member = _context.TryGetMentionedMember(mention.Id);
User.CreateUnknownUser(mention.Id); var name = member?.User.Name ?? "Unknown";
_buffer.Append($"@{user.Name}"); _buffer.Append($"@{name}");
} }
else if (mention.Type == MentionType.Channel) else if (mention.Type == MentionType.Channel)
{ {
var channel = _context.MentionableChannels.FirstOrDefault(c => c.Id == mention.Id) ?? var channel = _context.TryGetMentionedChannel(mention.Id);
Channel.CreateDeletedChannel(mention.Id); var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{channel.Name}"); _buffer.Append($"#{name}");
} }
else if (mention.Type == MentionType.Role) else if (mention.Type == MentionType.Role)
{ {
var role = _context.MentionableRoles.FirstOrDefault(r => r.Id == mention.Id) ?? var role = _context.TryGetMentionedRole(mention.Id);
Role.CreateDeletedRole(mention.Id); var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{role.Name}"); _buffer.Append($"@{name}");
} }
return base.VisitMention(mention); return base.VisitMention(mention);

View file

@ -76,8 +76,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
foreach (var field in embed.Fields) foreach (var field in embed.Fields)
{ {
buffer buffer
.AppendLineIfNotNullOrWhiteSpace(field.Name) .AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Name))
.AppendLineIfNotNullOrWhiteSpace(field.Value); .AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Value));
} }
buffer buffer
@ -135,7 +135,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
buffer.Append('=', 62).AppendLine(); buffer.Append('=', 62).AppendLine();
buffer.AppendLine($"Guild: {Context.Guild.Name}"); buffer.AppendLine($"Guild: {Context.Guild.Name}");
buffer.AppendLine($"Channel: {Context.Channel.Name}"); buffer.AppendLine($"Channel: {Context.Channel.Category} / {Context.Channel.Name}");
if (!string.IsNullOrWhiteSpace(Context.Channel.Topic)) if (!string.IsNullOrWhiteSpace(Context.Channel.Topic))
buffer.AppendLine($"Topic: {Context.Channel.Topic}"); buffer.AppendLine($"Topic: {Context.Channel.Topic}");

View file

@ -4,12 +4,10 @@ namespace DiscordChatExporter.Domain.Internal
{ {
internal static class ColorExtensions internal static class ColorExtensions
{ {
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color); public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff; public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
public static string ToHexString(this Color color) => $"#{color.ToRgb():x6}";
public static string ToRgbString(this Color color) => $"{color.R}, {color.G}, {color.B}";
} }
} }

View file

@ -0,0 +1,8 @@
using DiscordChatExporter.Domain.Discord.Models;
namespace DiscordChatExporter.Gui.Behaviors
{
public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<Channel>
{
}
}

View file

@ -1,8 +0,0 @@
using DiscordChatExporter.Gui.ViewModels.Components;
namespace DiscordChatExporter.Gui.Behaviors
{
public class ChannelViewModelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<ChannelViewModel>
{
}
}

View file

@ -1,17 +0,0 @@
using DiscordChatExporter.Domain.Discord.Models;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Components
{
public partial class ChannelViewModel : PropertyChangedBase
{
public Channel? Model { get; set; }
public string? Category { get; set; }
}
public partial class ChannelViewModel
{
public static implicit operator Channel?(ChannelViewModel? viewModel) => viewModel?.Model;
}
}

View file

@ -1,18 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Domain.Discord.Models;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Components
{
public partial class GuildViewModel : PropertyChangedBase
{
public Guild? Model { get; set; }
public IReadOnlyList<ChannelViewModel>? Channels { get; set; }
}
public partial class GuildViewModel
{
public static implicit operator Guild?(GuildViewModel? viewModel) => viewModel?.Model;
}
}

View file

@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs namespace DiscordChatExporter.Gui.ViewModels.Dialogs
@ -13,9 +13,9 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
private readonly DialogManager _dialogManager; private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;
public GuildViewModel? Guild { get; set; } public Guild? Guild { get; set; }
public IReadOnlyList<ChannelViewModel>? Channels { get; set; } public IReadOnlyList<Channel>? Channels { get; set; }
public bool IsSingleChannel => Channels == null || Channels.Count == 1; public bool IsSingleChannel => Channels == null || Channels.Count == 1;
@ -61,7 +61,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
var channel = Channels.Single(); var channel = Channels.Single();
// Generate default file name // Generate default file name
var defaultFileName = ChannelExporter.GetDefaultExportFileName(Guild!, channel!, SelectedFormat, After, Before); var defaultFileName = ChannelExporter.GetDefaultExportFileName(Guild!, channel, SelectedFormat, After, Before);
// Generate filter // Generate filter
var ext = SelectedFormat.GetFileExtension(); var ext = SelectedFormat.GetFileExtension();

View file

@ -1,33 +1,13 @@
using System.Collections.Generic; using System.Collections.Generic;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs; using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework namespace DiscordChatExporter.Gui.ViewModels.Framework
{ {
public static class Extensions public static class Extensions
{ {
public static ChannelViewModel CreateChannelViewModel(this IViewModelFactory factory, Channel model, string? category = null)
{
var viewModel = factory.CreateChannelViewModel();
viewModel.Model = model;
viewModel.Category = category;
return viewModel;
}
public static GuildViewModel CreateGuildViewModel(this IViewModelFactory factory, Guild model,
IReadOnlyList<ChannelViewModel> channels)
{
var viewModel = factory.CreateGuildViewModel();
viewModel.Model = model;
viewModel.Channels = channels;
return viewModel;
}
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory, public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
GuildViewModel guild, IReadOnlyList<ChannelViewModel> channels) Guild guild, IReadOnlyList<Channel> channels)
{ {
var viewModel = factory.CreateExportSetupViewModel(); var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild; viewModel.Guild = guild;

View file

@ -1,15 +1,10 @@
using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework namespace DiscordChatExporter.Gui.ViewModels.Framework
{ {
// Used to instantiate new view models while making use of dependency injection // Used to instantiate new view models while making use of dependency injection
public interface IViewModelFactory public interface IViewModelFactory
{ {
ChannelViewModel CreateChannelViewModel();
GuildViewModel CreateGuildViewModel();
ExportSetupViewModel CreateExportSetupViewModel(); ExportSetupViewModel CreateExportSetupViewModel();
SettingsViewModel CreateSettingsViewModel(); SettingsViewModel CreateSettingsViewModel();

View file

@ -9,7 +9,6 @@ using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Domain.Utilities;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress; using Gress;
using MaterialDesignThemes.Wpf; using MaterialDesignThemes.Wpf;
@ -37,11 +36,17 @@ namespace DiscordChatExporter.Gui.ViewModels
public string? TokenValue { get; set; } public string? TokenValue { get; set; }
public IReadOnlyList<GuildViewModel>? AvailableGuilds { get; private set; } private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
public GuildViewModel? SelectedGuild { get; set; } public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.Keys.ToArray();
public IReadOnlyList<ChannelViewModel>? SelectedChannels { get; set; } public Guild? SelectedGuild { get; set; }
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild != null
? GuildChannelMap?[SelectedGuild]
: null;
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
public RootViewModel( public RootViewModel(
IViewModelFactory viewModelFactory, IViewModelFactory viewModelFactory,
@ -142,71 +147,18 @@ namespace DiscordChatExporter.Gui.ViewModels
var discord = new DiscordClient(token); var discord = new DiscordClient(token);
var availableGuilds = new List<GuildViewModel>(); var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync())
// Direct messages
{
var guild = Guild.DirectMessages;
var channels = await discord.GetDirectMessageChannelsAsync();
// Create channel view models
var channelViewModels = new List<ChannelViewModel>();
foreach (var channel in channels)
{
// Get fake category
var category = channel.Type == ChannelType.DirectTextChat ? "Private" : "Group";
// Create channel view model
var channelViewModel = _viewModelFactory.CreateChannelViewModel(channel, category);
// Add to list
channelViewModels.Add(channelViewModel);
}
// Create guild view model
var guildViewModel = _viewModelFactory.CreateGuildViewModel(guild,
channelViewModels.OrderBy(c => c.Category)
.ThenBy(c => c.Model!.Name)
.ToArray());
// Add to list
availableGuilds.Add(guildViewModel);
}
// Guilds
var guilds = await discord.GetUserGuildsAsync();
foreach (var guild in guilds)
{ {
var channels = await discord.GetGuildChannelsAsync(guild.Id); var channels = await discord.GetGuildChannelsAsync(guild.Id);
var categoryChannels = channels.Where(c => c.Type == ChannelType.GuildCategory).ToArray(); guildChannelMap[guild] = channels
var exportableChannels = channels.Where(c => c.IsTextChannel).ToArray(); .OrderBy(c => c.Category)
.ThenBy(c => c.Name)
// Create channel view models .ToArray();
var channelViewModels = new List<ChannelViewModel>();
foreach (var channel in exportableChannels)
{
// Get category
var category = categoryChannels.FirstOrDefault(c => c.Id == channel.ParentId)?.Name;
// Create channel view model
var channelViewModel = _viewModelFactory.CreateChannelViewModel(channel, category);
// Add to list
channelViewModels.Add(channelViewModel);
}
// Create guild view model
var guildViewModel = _viewModelFactory.CreateGuildViewModel(guild,
channelViewModels.OrderBy(c => c.Category)
.ThenBy(c => c.Model!.Name)
.ToArray());
// Add to list
availableGuilds.Add(guildViewModel);
} }
AvailableGuilds = availableGuilds; GuildChannelMap = guildChannelMap;
SelectedGuild = AvailableGuilds.FirstOrDefault(); SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
} }
catch (DiscordChatExporterException ex) when (!ex.IsCritical) catch (DiscordChatExporterException ex) when (!ex.IsCritical)
{ {

View file

@ -26,7 +26,7 @@
Width="32" Width="32"
Height="32"> Height="32">
<Ellipse.Fill> <Ellipse.Fill>
<ImageBrush ImageSource="{Binding Guild.Model.IconUrl}" /> <ImageBrush ImageSource="{Binding Guild.IconUrl}" />
</Ellipse.Fill> </Ellipse.Fill>
</Ellipse> </Ellipse>
@ -54,8 +54,8 @@
<Run Text="/" /> <Run Text="/" />
<Run <Run
Foreground="{DynamicResource PrimaryTextBrush}" Foreground="{DynamicResource PrimaryTextBrush}"
Text="{Binding Channels[0].Model.Name, Mode=OneWay}" Text="{Binding Channels[0].Name, Mode=OneWay}"
ToolTip="{Binding Channels[0].Model.Name, Mode=OneWay}" /> ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock> </TextBlock>
</Grid> </Grid>

View file

@ -226,7 +226,7 @@
Margin="-8" Margin="-8"
Background="Transparent" Background="Transparent"
Cursor="Hand" Cursor="Hand"
ToolTip="{Binding Model.Name}"> ToolTip="{Binding Name}">
<!-- Guild icon placeholder --> <!-- Guild icon placeholder -->
<Ellipse <Ellipse
Width="48" Width="48"
@ -240,7 +240,7 @@
Height="48" Height="48"
Margin="12,4,12,4"> Margin="12,4,12,4">
<Ellipse.Fill> <Ellipse.Fill>
<ImageBrush ImageSource="{Binding Model.IconUrl}" /> <ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill> </Ellipse.Fill>
</Ellipse> </Ellipse>
</Grid> </Grid>
@ -253,11 +253,11 @@
<Border Grid.Column="1"> <Border Grid.Column="1">
<ListBox <ListBox
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
ItemsSource="{Binding SelectedGuild.Channels}" ItemsSource="{Binding AvailableChannels}"
SelectionMode="Extended" SelectionMode="Extended"
TextSearch.TextPath="Model.Name"> TextSearch.TextPath="Model.Name">
<i:Interaction.Behaviors> <i:Interaction.Behaviors>
<behaviors:ChannelViewModelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" /> <behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</i:Interaction.Behaviors> </i:Interaction.Behaviors>
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
@ -286,7 +286,7 @@
FontSize="14"> FontSize="14">
<Run Foreground="{DynamicResource SecondaryTextBrush}" Text="{Binding Category, Mode=OneWay}" /> <Run Foreground="{DynamicResource SecondaryTextBrush}" Text="{Binding Category, Mode=OneWay}" />
<Run Text="/" /> <Run Text="/" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding Model.Name, Mode=OneWay}" /> <Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding Name, Mode=OneWay}" />
</TextBlock> </TextBlock>
<!-- Is selected checkmark --> <!-- Is selected checkmark -->