diff --git a/DiscordChatExporter.Cli/Commands/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs similarity index 62% rename from DiscordChatExporter.Cli/Commands/ExportCommandBase.cs rename to DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index 2edfff0..3732151 100644 --- a/DiscordChatExporter.Cli/Commands/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -4,17 +4,13 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; using CliFx.Utilities; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exporting; -namespace DiscordChatExporter.Cli.Commands +namespace DiscordChatExporter.Cli.Commands.Base { public abstract class ExportCommandBase : TokenCommandBase { - protected SettingsService SettingsService { get; } - - protected ExportService ExportService { get; } - [CommandOption("format", 'f', Description = "Output file format.")] public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark; @@ -31,25 +27,17 @@ namespace DiscordChatExporter.Cli.Commands public int? PartitionLimit { get; set; } [CommandOption("dateformat", Description = "Date format used in output.")] - public string? DateFormat { get; set; } + public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt"; - protected ExportCommandBase(SettingsService settingsService, DataService dataService, ExportService exportService) - : base(dataService) - { - SettingsService = settingsService; - ExportService = exportService; - } + protected Exporter GetExporter() => new Exporter(GetDiscordClient()); protected async ValueTask ExportAsync(IConsole console, Guild guild, Channel channel) { - if (!string.IsNullOrWhiteSpace(DateFormat)) - SettingsService.DateFormat = DateFormat; - - console.Output.Write($"Exporting channel [{channel.Name}]... "); + console.Output.Write($"Exporting channel '{channel.Name}'... "); var progress = console.CreateProgressTicker(); - await ExportService.ExportChatLogAsync(Token, guild, channel, - OutputPath, ExportFormat, PartitionLimit, + await GetExporter().ExportChatLogAsync(guild, channel, + OutputPath, ExportFormat, DateFormat, PartitionLimit, After, Before, progress); console.Output.WriteLine(); @@ -58,13 +46,13 @@ namespace DiscordChatExporter.Cli.Commands protected async ValueTask ExportAsync(IConsole console, Channel channel) { - var guild = await DataService.GetGuildAsync(Token, channel.GuildId); + var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId); await ExportAsync(console, guild, channel); } protected async ValueTask ExportAsync(IConsole console, string channelId) { - var channel = await DataService.GetChannelAsync(Token, channelId); + var channel = await GetDiscordClient().GetChannelAsync(channelId); await ExportAsync(console, channel); } } diff --git a/DiscordChatExporter.Cli/Commands/ExportMultipleCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs similarity index 52% rename from DiscordChatExporter.Cli/Commands/ExportMultipleCommandBase.cs rename to DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs index d6c86db..56d6aa7 100644 --- a/DiscordChatExporter.Cli/Commands/ExportMultipleCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs @@ -1,38 +1,28 @@ using System.Collections.Generic; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; using CliFx; using CliFx.Attributes; using CliFx.Utilities; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Models.Exceptions; -using DiscordChatExporter.Core.Services; -using DiscordChatExporter.Core.Services.Exceptions; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exceptions; +using DiscordChatExporter.Domain.Utilities; using Gress; using Tyrrrz.Extensions; -namespace DiscordChatExporter.Cli.Commands +namespace DiscordChatExporter.Cli.Commands.Base { public abstract class ExportMultipleCommandBase : ExportCommandBase { [CommandOption("parallel", Description = "Export this number of separate channels in parallel.")] public int ParallelLimit { get; set; } = 1; - protected ExportMultipleCommandBase(SettingsService settingsService, DataService dataService, ExportService exportService) - : base(settingsService, dataService, exportService) - { - } - protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList channels) { // This uses a separate route from ExportCommandBase because the progress ticker is not thread-safe // Ugly code ahead. Will need to refactor. - if (!string.IsNullOrWhiteSpace(DateFormat)) - SettingsService.DateFormat = DateFormat; - // Progress console.Output.Write($"Exporting {channels.Count} channels... "); var ticker = console.CreateProgressTicker(); @@ -44,41 +34,33 @@ namespace DiscordChatExporter.Cli.Commands var operations = progressManager.CreateOperations(channels.Count); // Export channels - using var semaphore = new SemaphoreSlim(ParallelLimit.ClampMin(1)); var errors = new List(); - await Task.WhenAll(channels.Select(async (channel, i) => + var successfulExportCount = 0; + await channels.Zip(operations).ParallelForEachAsync(async tuple => { - var operation = operations[i]; - await semaphore.WaitAsync(); - - var guild = await DataService.GetGuildAsync(Token, channel.GuildId); + var (channel, operation) = tuple; try { - await ExportService.ExportChatLogAsync(Token, guild, channel, - OutputPath, ExportFormat, PartitionLimit, + var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId); + + await GetExporter().ExportChatLogAsync(guild, channel, + OutputPath, ExportFormat, DateFormat, PartitionLimit, After, Before, operation); + + Interlocked.Increment(ref successfulExportCount); } - catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden) - { - errors.Add("You don't have access to this channel."); - } - catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - errors.Add("This channel doesn't exist."); - } - catch (DomainException ex) + catch (DiscordChatExporterException ex) when (!ex.IsCritical) { errors.Add(ex.Message); } finally { - semaphore.Release(); operation.Dispose(); } - })); + }, ParallelLimit.ClampMin(1)); ticker.Report(1); console.Output.WriteLine(); @@ -86,7 +68,7 @@ namespace DiscordChatExporter.Cli.Commands foreach (var error in errors) console.Error.WriteLine(error); - console.Output.WriteLine("Done."); + console.Output.WriteLine($"Successfully exported {successfulExportCount} channel(s)."); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/TokenCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/TokenCommandBase.cs similarity index 60% rename from DiscordChatExporter.Cli/Commands/TokenCommandBase.cs rename to DiscordChatExporter.Cli/Commands/Base/TokenCommandBase.cs index 101e79f..715996f 100644 --- a/DiscordChatExporter.Cli/Commands/TokenCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/TokenCommandBase.cs @@ -1,15 +1,12 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Domain.Discord; -namespace DiscordChatExporter.Cli.Commands +namespace DiscordChatExporter.Cli.Commands.Base { public abstract class TokenCommandBase : ICommand { - protected DataService DataService { get; } - [CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN", Description = "Authorization token.")] public string TokenValue { get; set; } = ""; @@ -18,12 +15,9 @@ namespace DiscordChatExporter.Cli.Commands Description = "Whether this authorization token belongs to a bot.")] public bool IsBotToken { get; set; } - protected AuthToken Token => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue); + protected AuthToken GetAuthToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue); - protected TokenCommandBase(DataService dataService) - { - DataService = dataService; - } + protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken()); public abstract ValueTask ExecuteAsync(IConsole console); } diff --git a/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs b/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs index 0890c14..4811ddb 100644 --- a/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs @@ -1,7 +1,7 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands { @@ -11,11 +11,6 @@ namespace DiscordChatExporter.Cli.Commands [CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")] public string ChannelId { get; set; } = ""; - public ExportChannelCommand(SettingsService settingsService, DataService dataService, ExportService exportService) - : base(settingsService, dataService, exportService) - { - } - public override async ValueTask ExecuteAsync(IConsole console) => await ExportAsync(console, ChannelId); } } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs b/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs index 0e2f022..913d4ce 100644 --- a/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs @@ -2,21 +2,16 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands { [Command("exportdm", Description = "Export all direct message channels.")] public class ExportDirectMessagesCommand : ExportMultipleCommandBase { - public ExportDirectMessagesCommand(SettingsService settingsService, DataService dataService, ExportService exportService) - : base(settingsService, dataService, exportService) - { - } - public override async ValueTask ExecuteAsync(IConsole console) { - var directMessageChannels = await DataService.GetDirectMessageChannelsAsync(Token); + var directMessageChannels = await GetDiscordClient().GetDirectMessageChannelsAsync(); var channels = directMessageChannels.OrderBy(c => c.Name).ToArray(); await ExportMultipleAsync(console, channels); diff --git a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs index 118fb6c..f15e806 100644 --- a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs @@ -2,8 +2,7 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands { @@ -13,17 +12,12 @@ namespace DiscordChatExporter.Cli.Commands [CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")] public string GuildId { get; set; } = ""; - public ExportGuildCommand(SettingsService settingsService, DataService dataService, ExportService exportService) - : base(settingsService, dataService, exportService) - { - } - public override async ValueTask ExecuteAsync(IConsole console) { - var guildChannels = await DataService.GetGuildChannelsAsync(Token, GuildId); + var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId); var channels = guildChannels - .Where(c => c.Type.IsExportable()) + .Where(c => c.IsTextChannel) .OrderBy(c => c.Name) .ToArray(); diff --git a/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs b/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs index b08d437..d1a3a7b 100644 --- a/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs @@ -2,8 +2,7 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands { @@ -13,17 +12,12 @@ namespace DiscordChatExporter.Cli.Commands [CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")] public string GuildId { get; set; } = ""; - public GetChannelsCommand(DataService dataService) - : base(dataService) - { - } - public override async ValueTask ExecuteAsync(IConsole console) { - var guildChannels = await DataService.GetGuildChannelsAsync(Token, GuildId); + var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId); var channels = guildChannels - .Where(c => c.Type.IsExportable()) + .Where(c => c.IsTextChannel) .OrderBy(c => c.Name) .ToArray(); diff --git a/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs b/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs index 3c281d9..58cdfe8 100644 --- a/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs @@ -2,21 +2,16 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands { [Command("dm", Description = "Get the list of direct message channels.")] public class GetDirectMessageChannelsCommand : TokenCommandBase { - public GetDirectMessageChannelsCommand(DataService dataService) - : base(dataService) - { - } - public override async ValueTask ExecuteAsync(IConsole console) { - var directMessageChannels = await DataService.GetDirectMessageChannelsAsync(Token); + var directMessageChannels = await GetDiscordClient().GetDirectMessageChannelsAsync(); var channels = directMessageChannels.OrderBy(c => c.Name).ToArray(); foreach (var channel in channels) diff --git a/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs b/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs index f90166b..4f1fdd6 100644 --- a/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs @@ -2,21 +2,17 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Cli.Commands.Base; +using DiscordChatExporter.Domain.Discord; namespace DiscordChatExporter.Cli.Commands { [Command("guilds", Description = "Get the list of accessible guilds.")] public class GetGuildsCommand : TokenCommandBase { - public GetGuildsCommand(DataService dataService) - : base(dataService) - { - } - public override async ValueTask ExecuteAsync(IConsole console) { - var guilds = await DataService.GetUserGuildsAsync(Token); + var guilds = await GetDiscordClient().GetUserGuildsAsync(); foreach (var guild in guilds.OrderBy(g => g.Name)) console.Output.WriteLine($"{guild.Id} | {guild.Name}"); diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj index 59bd35c..8973b4a 100644 --- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj @@ -9,13 +9,11 @@ - - - + \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Program.cs b/DiscordChatExporter.Cli/Program.cs index 16841ff..5dbbdee 100644 --- a/DiscordChatExporter.Cli/Program.cs +++ b/DiscordChatExporter.Cli/Program.cs @@ -1,44 +1,14 @@ -using System; -using System.Threading.Tasks; +using System.Threading.Tasks; using CliFx; -using DiscordChatExporter.Cli.Commands; -using DiscordChatExporter.Core.Services; -using Microsoft.Extensions.DependencyInjection; namespace DiscordChatExporter.Cli { public static class Program { - private static IServiceProvider ConfigureServices() - { - var services = new ServiceCollection(); - - // Register services - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Register commands - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - return services.BuildServiceProvider(); - } - - public static async Task Main(string[] args) - { - var serviceProvider = ConfigureServices(); - - return await new CliApplicationBuilder() + public static async Task Main(string[] args) => + await new CliApplicationBuilder() .AddCommandsFromThisAssembly() - .UseTypeActivator(serviceProvider.GetService) .Build() .RunAsync(args); - } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Ast/FormattedNode.cs b/DiscordChatExporter.Core.Markdown/Ast/FormattedNode.cs deleted file mode 100644 index f4bac0d..0000000 --- a/DiscordChatExporter.Core.Markdown/Ast/FormattedNode.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; - -namespace DiscordChatExporter.Core.Markdown.Ast -{ - public class FormattedNode : Node - { - public TextFormatting Formatting { get; } - - public IReadOnlyList Children { get; } - - public FormattedNode(TextFormatting formatting, IReadOnlyList children) - { - Formatting = formatting; - Children = children; - } - - public override string ToString() => $"<{Formatting}> ({Children.Count} direct children)"; - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Ast/MentionType.cs b/DiscordChatExporter.Core.Markdown/Ast/MentionType.cs deleted file mode 100644 index 3d7e093..0000000 --- a/DiscordChatExporter.Core.Markdown/Ast/MentionType.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace DiscordChatExporter.Core.Markdown.Ast -{ - public enum MentionType - { - Meta, - User, - Channel, - Role - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Ast/Node.cs b/DiscordChatExporter.Core.Markdown/Ast/Node.cs deleted file mode 100644 index e591d3c..0000000 --- a/DiscordChatExporter.Core.Markdown/Ast/Node.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DiscordChatExporter.Core.Markdown.Ast -{ - public abstract class Node - { - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Ast/TextFormatting.cs b/DiscordChatExporter.Core.Markdown/Ast/TextFormatting.cs deleted file mode 100644 index 3e4b49c..0000000 --- a/DiscordChatExporter.Core.Markdown/Ast/TextFormatting.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace DiscordChatExporter.Core.Markdown.Ast -{ - public enum TextFormatting - { - Bold, - Italic, - Underline, - Strikethrough, - Spoiler, - Quote - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/DiscordChatExporter.Core.Markdown.csproj b/DiscordChatExporter.Core.Markdown/DiscordChatExporter.Core.Markdown.csproj deleted file mode 100644 index e22983e..0000000 --- a/DiscordChatExporter.Core.Markdown/DiscordChatExporter.Core.Markdown.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Internal/IMatcher.cs b/DiscordChatExporter.Core.Markdown/Internal/IMatcher.cs deleted file mode 100644 index e61e6af..0000000 --- a/DiscordChatExporter.Core.Markdown/Internal/IMatcher.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace DiscordChatExporter.Core.Markdown.Internal -{ - internal interface IMatcher - { - ParsedMatch? Match(StringPart stringPart); - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/AuthToken.cs b/DiscordChatExporter.Core.Models/AuthToken.cs deleted file mode 100644 index 911d5a9..0000000 --- a/DiscordChatExporter.Core.Models/AuthToken.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace DiscordChatExporter.Core.Models -{ - public class AuthToken - { - public AuthTokenType Type { get; } - - public string Value { get; } - - public AuthToken(AuthTokenType type, string value) - { - Type = type; - Value = value; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/AuthTokenType.cs b/DiscordChatExporter.Core.Models/AuthTokenType.cs deleted file mode 100644 index 1d1e445..0000000 --- a/DiscordChatExporter.Core.Models/AuthTokenType.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace DiscordChatExporter.Core.Models -{ - public enum AuthTokenType - { - User, - Bot - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Channel.cs b/DiscordChatExporter.Core.Models/Channel.cs deleted file mode 100644 index 1035030..0000000 --- a/DiscordChatExporter.Core.Models/Channel.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace DiscordChatExporter.Core.Models -{ - // https://discordapp.com/developers/docs/resources/channel#channel-object - - public partial class Channel : IHasId - { - public string Id { get; } - - public string? ParentId { get; } - - public string GuildId { get; } - - public string Name { get; } - - public string? Topic { get; } - - public ChannelType Type { get; } - - public Channel(string id, string? parentId, string guildId, string name, string? topic, ChannelType type) - { - Id = id; - ParentId = parentId; - GuildId = guildId; - Name = name; - Topic = topic; - Type = type; - } - - public override string ToString() => Name; - } - - public partial class Channel - { - public static Channel CreateDeletedChannel(string id) => - new Channel(id, null, "unknown-guild", "deleted-channel", null, ChannelType.GuildTextChat); - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/ChannelType.cs b/DiscordChatExporter.Core.Models/ChannelType.cs deleted file mode 100644 index f97153a..0000000 --- a/DiscordChatExporter.Core.Models/ChannelType.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace DiscordChatExporter.Core.Models -{ - // https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types - // Order of enum fields needs to match the order in the docs. - - public enum ChannelType - { - GuildTextChat, - DirectTextChat, - GuildVoiceChat, - DirectGroupTextChat, - GuildCategory, - GuildNews, - GuildStore - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/DiscordChatExporter.Core.Models.csproj b/DiscordChatExporter.Core.Models/DiscordChatExporter.Core.Models.csproj deleted file mode 100644 index e22983e..0000000 --- a/DiscordChatExporter.Core.Models/DiscordChatExporter.Core.Models.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Exceptions/DomainException.cs b/DiscordChatExporter.Core.Models/Exceptions/DomainException.cs deleted file mode 100644 index d81d166..0000000 --- a/DiscordChatExporter.Core.Models/Exceptions/DomainException.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace DiscordChatExporter.Core.Models.Exceptions -{ - public class DomainException : Exception - { - public DomainException(string message) - : base(message) - { - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/ExportFormat.cs b/DiscordChatExporter.Core.Models/ExportFormat.cs deleted file mode 100644 index 302e228..0000000 --- a/DiscordChatExporter.Core.Models/ExportFormat.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace DiscordChatExporter.Core.Models -{ - public enum ExportFormat - { - PlainText, - HtmlDark, - HtmlLight, - Csv, - Json - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Extensions.cs b/DiscordChatExporter.Core.Models/Extensions.cs deleted file mode 100644 index 4ec67b5..0000000 --- a/DiscordChatExporter.Core.Models/Extensions.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; - -namespace DiscordChatExporter.Core.Models -{ - public static class Extensions - { - public static bool IsExportable(this ChannelType channelType) => - channelType == ChannelType.GuildTextChat || - channelType == ChannelType.DirectTextChat || - channelType == ChannelType.DirectGroupTextChat || - channelType == ChannelType.GuildNews || - channelType == ChannelType.GuildStore; - - public static string GetFileExtension(this ExportFormat format) => - format switch - { - ExportFormat.PlainText => "txt", - ExportFormat.HtmlDark => "html", - ExportFormat.HtmlLight => "html", - ExportFormat.Csv => "csv", - ExportFormat.Json => "json", - _ => throw new ArgumentOutOfRangeException(nameof(format)) - }; - - public static string GetDisplayName(this ExportFormat format) => - format switch - { - ExportFormat.PlainText => "TXT", - ExportFormat.HtmlDark => "HTML (Dark)", - ExportFormat.HtmlLight => "HTML (Light)", - ExportFormat.Csv => "CSV", - ExportFormat.Json => "JSON", - _ => throw new ArgumentOutOfRangeException(nameof(format)) - }; - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/MessageGroup.cs b/DiscordChatExporter.Core.Models/MessageGroup.cs deleted file mode 100644 index b49f872..0000000 --- a/DiscordChatExporter.Core.Models/MessageGroup.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace DiscordChatExporter.Core.Models -{ - // Used for grouping contiguous messages in HTML export - - public class MessageGroup - { - public User Author { get; } - - public DateTimeOffset Timestamp { get; } - - public IReadOnlyList Messages { get; } - - public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList messages) - { - Author = author; - Timestamp = timestamp; - Messages = messages; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/MessageType.cs b/DiscordChatExporter.Core.Models/MessageType.cs deleted file mode 100644 index e2bc56f..0000000 --- a/DiscordChatExporter.Core.Models/MessageType.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace DiscordChatExporter.Core.Models -{ - // https://discordapp.com/developers/docs/resources/channel#message-object-message-types - - public enum MessageType - { - Default, - RecipientAdd, - RecipientRemove, - Call, - ChannelNameChange, - ChannelIconChange, - ChannelPinnedMessage, - GuildMemberJoin - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/DiscordChatExporter.Core.Rendering.csproj b/DiscordChatExporter.Core.Rendering/DiscordChatExporter.Core.Rendering.csproj deleted file mode 100644 index b8aa5f5..0000000 --- a/DiscordChatExporter.Core.Rendering/DiscordChatExporter.Core.Rendering.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Formatters/CsvMessageWriter.cs b/DiscordChatExporter.Core.Rendering/Formatters/CsvMessageWriter.cs deleted file mode 100644 index 78b353b..0000000 --- a/DiscordChatExporter.Core.Rendering/Formatters/CsvMessageWriter.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.IO; -using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Rendering.Logic; - -namespace DiscordChatExporter.Core.Rendering.Formatters -{ - public class CsvMessageWriter : MessageWriterBase - { - private readonly TextWriter _writer; - - public CsvMessageWriter(Stream stream, RenderContext context) - : base(stream, context) - { - _writer = new StreamWriter(stream); - } - - public override async Task WritePreambleAsync() - { - await _writer.WriteLineAsync(CsvRenderingLogic.FormatHeader(Context)); - } - - public override async Task WriteMessageAsync(Message message) - { - await _writer.WriteLineAsync(CsvRenderingLogic.FormatMessage(Context, message)); - } - - public override async ValueTask DisposeAsync() - { - await _writer.DisposeAsync(); - await base.DisposeAsync(); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Formatters/PlainTextMessageWriter.cs b/DiscordChatExporter.Core.Rendering/Formatters/PlainTextMessageWriter.cs deleted file mode 100644 index 18466ff..0000000 --- a/DiscordChatExporter.Core.Rendering/Formatters/PlainTextMessageWriter.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.IO; -using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Rendering.Logic; - -namespace DiscordChatExporter.Core.Rendering.Formatters -{ - public class PlainTextMessageWriter : MessageWriterBase - { - private readonly TextWriter _writer; - - private long _messageCount; - - public PlainTextMessageWriter(Stream stream, RenderContext context) - : base(stream, context) - { - _writer = new StreamWriter(stream); - } - - public override async Task WritePreambleAsync() - { - await _writer.WriteLineAsync(PlainTextRenderingLogic.FormatPreamble(Context)); - } - - public override async Task WriteMessageAsync(Message message) - { - await _writer.WriteLineAsync(PlainTextRenderingLogic.FormatMessage(Context, message)); - await _writer.WriteLineAsync(); - - _messageCount++; - } - - public override async Task WritePostambleAsync() - { - await _writer.WriteLineAsync(); - await _writer.WriteLineAsync(PlainTextRenderingLogic.FormatPostamble(_messageCount)); - } - - public override async ValueTask DisposeAsync() - { - await _writer.DisposeAsync(); - await base.DisposeAsync(); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Logic/CsvRenderingLogic.cs b/DiscordChatExporter.Core.Rendering/Logic/CsvRenderingLogic.cs deleted file mode 100644 index 22b26ce..0000000 --- a/DiscordChatExporter.Core.Rendering/Logic/CsvRenderingLogic.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Linq; -using System.Text; -using DiscordChatExporter.Core.Models; -using Tyrrrz.Extensions; - -using static DiscordChatExporter.Core.Rendering.Logic.SharedRenderingLogic; - -namespace DiscordChatExporter.Core.Rendering.Logic -{ - public static class CsvRenderingLogic - { - // Header is always the same - public static string FormatHeader(RenderContext context) => "AuthorID,Author,Date,Content,Attachments,Reactions"; - - private static string EncodeValue(string value) - { - value = value.Replace("\"", "\"\""); - return $"\"{value}\""; - } - - public static string FormatMarkdown(RenderContext context, string markdown) => - PlainTextRenderingLogic.FormatMarkdown(context, markdown); - - public static string FormatMessage(RenderContext context, Message message) - { - var buffer = new StringBuilder(); - - buffer - .Append(EncodeValue(message.Author.Id)).Append(',') - .Append(EncodeValue(message.Author.FullName)).Append(',') - .Append(EncodeValue(FormatDate(message.Timestamp, context.DateFormat))).Append(',') - .Append(EncodeValue(FormatMarkdown(context, message.Content ?? ""))).Append(',') - .Append(EncodeValue(message.Attachments.Select(a => a.Url).JoinToString(","))).Append(',') - .Append(EncodeValue(message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(","))); - - return buffer.ToString(); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Logic/HtmlRenderingLogic.cs b/DiscordChatExporter.Core.Rendering/Logic/HtmlRenderingLogic.cs deleted file mode 100644 index 23f9096..0000000 --- a/DiscordChatExporter.Core.Rendering/Logic/HtmlRenderingLogic.cs +++ /dev/null @@ -1,172 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using System.Net; -using System.Text.RegularExpressions; -using DiscordChatExporter.Core.Markdown; -using DiscordChatExporter.Core.Markdown.Ast; -using DiscordChatExporter.Core.Models; -using Tyrrrz.Extensions; - -namespace DiscordChatExporter.Core.Rendering.Logic -{ - internal static class HtmlRenderingLogic - { - public static bool CanBeGrouped(Message message1, Message message2) - { - if (!string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal)) - return false; - - // Bots can post message under different usernames, so need to check this too - if (!string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal)) - return false; - - if ((message2.Timestamp - message1.Timestamp).Duration().TotalMinutes > 7) - return false; - - return true; - } - - private static string HtmlEncode(string s) => WebUtility.HtmlEncode(s); - - private static string FormatMarkdownNode(RenderContext context, Node node, bool isJumbo) - { - // Text node - if (node is TextNode textNode) - { - // Return HTML-encoded text - return HtmlEncode(textNode.Text); - } - - // Formatted node - if (node is FormattedNode formattedNode) - { - // Recursively get inner html - var innerHtml = FormatMarkdownNodes(context, formattedNode.Children, false); - - // Bold - if (formattedNode.Formatting == TextFormatting.Bold) - return $"{innerHtml}"; - - // Italic - if (formattedNode.Formatting == TextFormatting.Italic) - return $"{innerHtml}"; - - // Underline - if (formattedNode.Formatting == TextFormatting.Underline) - return $"{innerHtml}"; - - // Strikethrough - if (formattedNode.Formatting == TextFormatting.Strikethrough) - return $"{innerHtml}"; - - // Spoiler - if (formattedNode.Formatting == TextFormatting.Spoiler) - return $"{innerHtml}"; - - // Quote - if (formattedNode.Formatting == TextFormatting.Quote) - return $"
{innerHtml}
"; - } - - // Inline code block node - if (node is InlineCodeBlockNode inlineCodeBlockNode) - { - return $"{HtmlEncode(inlineCodeBlockNode.Code)}"; - } - - // Multi-line code block node - if (node is MultiLineCodeBlockNode multilineCodeBlockNode) - { - // Set CSS class for syntax highlighting - var highlightCssClass = !string.IsNullOrWhiteSpace(multilineCodeBlockNode.Language) - ? $"language-{multilineCodeBlockNode.Language}" - : "nohighlight"; - - return $"
{HtmlEncode(multilineCodeBlockNode.Code)}
"; - } - - // Mention node - if (node is MentionNode mentionNode) - { - // Meta mention node - if (mentionNode.Type == MentionType.Meta) - { - return $"@{HtmlEncode(mentionNode.Id)}"; - } - - // User mention node - if (mentionNode.Type == MentionType.User) - { - var user = context.MentionableUsers.FirstOrDefault(u => u.Id == mentionNode.Id) ?? - User.CreateUnknownUser(mentionNode.Id); - - var nick = Guild.GetUserNick(context.Guild, user); - - return $"@{HtmlEncode(nick)}"; - } - - // Channel mention node - if (mentionNode.Type == MentionType.Channel) - { - var channel = context.MentionableChannels.FirstOrDefault(c => c.Id == mentionNode.Id) ?? - Channel.CreateDeletedChannel(mentionNode.Id); - - return $"#{HtmlEncode(channel.Name)}"; - } - - // Role mention node - if (mentionNode.Type == MentionType.Role) - { - var role = context.MentionableRoles.FirstOrDefault(r => r.Id == mentionNode.Id) ?? - Role.CreateDeletedRole(mentionNode.Id); - - var style = ""; - if (role.Color != Color.Black) - style = $"style=\"color: {role.ColorAsHex}; background-color: rgba({role.ColorAsRgb}, 0.1); font-weight: 400;\""; - - return $"@{HtmlEncode(role.Name)}"; - } - } - - // Emoji node - if (node is EmojiNode emojiNode) - { - // Get emoji image URL - var emojiImageUrl = Emoji.GetImageUrl(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated); - - // Make emoji large if it's jumbo - var jumboableCssClass = isJumbo ? "emoji--large" : null; - - return - $"\"{emojiNode.Name}\""; - } - - // Link node - if (node is LinkNode linkNode) - { - // Extract message ID if the link points to a Discord message - var linkedMessageId = Regex.Match(linkNode.Url, "^https?://discordapp.com/channels/.*?/(\\d+)/?$").Groups[1].Value; - - return string.IsNullOrWhiteSpace(linkedMessageId) - ? $"{HtmlEncode(linkNode.Title)}" - : $"{HtmlEncode(linkNode.Title)}"; - } - - // Throw on unexpected nodes - throw new InvalidOperationException($"Unexpected node [{node.GetType()}]."); - } - - private static string FormatMarkdownNodes(RenderContext context, IReadOnlyList nodes, bool isTopLevel) - { - // Emojis are jumbo if all top-level nodes are emoji nodes or whitespace text nodes - var isJumbo = isTopLevel && nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)); - - return nodes.Select(n => FormatMarkdownNode(context, n, isJumbo)).JoinToString(""); - } - - public static string FormatMarkdown(RenderContext context, string markdown) => - FormatMarkdownNodes(context, MarkdownParser.Parse(markdown), true); - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Logic/PlainTextRenderingLogic.cs b/DiscordChatExporter.Core.Rendering/Logic/PlainTextRenderingLogic.cs deleted file mode 100644 index f560559..0000000 --- a/DiscordChatExporter.Core.Rendering/Logic/PlainTextRenderingLogic.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using DiscordChatExporter.Core.Markdown; -using DiscordChatExporter.Core.Markdown.Ast; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Rendering.Internal; -using Tyrrrz.Extensions; - -using static DiscordChatExporter.Core.Rendering.Logic.SharedRenderingLogic; - -namespace DiscordChatExporter.Core.Rendering.Logic -{ - public static class PlainTextRenderingLogic - { - public static string FormatPreamble(RenderContext context) - { - var buffer = new StringBuilder(); - - buffer.Append('=', 62).AppendLine(); - buffer.AppendLine($"Guild: {context.Guild.Name}"); - buffer.AppendLine($"Channel: {context.Channel.Name}"); - - if (!string.IsNullOrWhiteSpace(context.Channel.Topic)) - buffer.AppendLine($"Topic: {context.Channel.Topic}"); - - if (context.After != null) - buffer.AppendLine($"After: {FormatDate(context.After.Value, context.DateFormat)}"); - - if (context.Before != null) - buffer.AppendLine($"Before: {FormatDate(context.Before.Value, context.DateFormat)}"); - - buffer.Append('=', 62).AppendLine(); - - return buffer.ToString(); - } - - public static string FormatPostamble(long messageCount) - { - var buffer = new StringBuilder(); - - buffer.Append('=', 62).AppendLine(); - buffer.AppendLine($"Exported {messageCount:N0} message(s)"); - buffer.Append('=', 62).AppendLine(); - - return buffer.ToString(); - } - - private static string FormatMarkdownNode(RenderContext context, Node node) - { - // Text node - if (node is TextNode textNode) - { - return textNode.Text; - } - - // Mention node - if (node is MentionNode mentionNode) - { - // Meta mention node - if (mentionNode.Type == MentionType.Meta) - { - return $"@{mentionNode.Id}"; - } - - // User mention node - if (mentionNode.Type == MentionType.User) - { - var user = context.MentionableUsers.FirstOrDefault(u => u.Id == mentionNode.Id) ?? - User.CreateUnknownUser(mentionNode.Id); - - return $"@{user.Name}"; - } - - // Channel mention node - if (mentionNode.Type == MentionType.Channel) - { - var channel = context.MentionableChannels.FirstOrDefault(c => c.Id == mentionNode.Id) ?? - Channel.CreateDeletedChannel(mentionNode.Id); - - return $"#{channel.Name}"; - } - - // Role mention node - if (mentionNode.Type == MentionType.Role) - { - var role = context.MentionableRoles.FirstOrDefault(r => r.Id == mentionNode.Id) ?? - Role.CreateDeletedRole(mentionNode.Id); - - return $"@{role.Name}"; - } - } - - // Emoji node - if (node is EmojiNode emojiNode) - { - return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name; - } - - // Throw on unexpected nodes - throw new InvalidOperationException($"Unexpected node [{node.GetType()}]."); - } - - public static string FormatMarkdown(RenderContext context, string markdown) => - MarkdownParser.ParseMinimal(markdown).Select(n => FormatMarkdownNode(context, n)).JoinToString(""); - - public static string FormatMessageHeader(RenderContext context, Message message) - { - var buffer = new StringBuilder(); - - // Timestamp & author - buffer - .Append($"[{FormatDate(message.Timestamp, context.DateFormat)}]") - .Append(' ') - .Append($"{message.Author.FullName}"); - - // Whether the message is pinned - if (message.IsPinned) - { - buffer.Append(' ').Append("(pinned)"); - } - - return buffer.ToString(); - } - - public static string FormatMessageContent(RenderContext context, Message message) - { - if (string.IsNullOrWhiteSpace(message.Content)) - return ""; - - return FormatMarkdown(context, message.Content); - } - - public static string FormatAttachments(IReadOnlyList attachments) - { - if (!attachments.Any()) - return ""; - - var buffer = new StringBuilder(); - - buffer - .AppendLine("{Attachments}") - .AppendJoin(Environment.NewLine, attachments.Select(a => a.Url)) - .AppendLine(); - - return buffer.ToString(); - } - - public static string FormatEmbeds(RenderContext context, IReadOnlyList embeds) - { - if (!embeds.Any()) - return ""; - - var buffer = new StringBuilder(); - - foreach (var embed in embeds) - { - buffer.AppendLine("{Embed}"); - - // Author name - if (!string.IsNullOrWhiteSpace(embed.Author?.Name)) - buffer.AppendLine(embed.Author.Name); - - // URL - if (!string.IsNullOrWhiteSpace(embed.Url)) - buffer.AppendLine(embed.Url); - - // Title - if (!string.IsNullOrWhiteSpace(embed.Title)) - buffer.AppendLine(FormatMarkdown(context, embed.Title)); - - // Description - if (!string.IsNullOrWhiteSpace(embed.Description)) - buffer.AppendLine(FormatMarkdown(context, embed.Description)); - - // Fields - foreach (var field in embed.Fields) - { - // Name - if (!string.IsNullOrWhiteSpace(field.Name)) - buffer.AppendLine(field.Name); - - // Value - if (!string.IsNullOrWhiteSpace(field.Value)) - buffer.AppendLine(field.Value); - } - - // Thumbnail URL - if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url)) - buffer.AppendLine(embed.Thumbnail?.Url); - - // Image URL - if (!string.IsNullOrWhiteSpace(embed.Image?.Url)) - buffer.AppendLine(embed.Image?.Url); - - // Footer text - if (!string.IsNullOrWhiteSpace(embed.Footer?.Text)) - buffer.AppendLine(embed.Footer?.Text); - - buffer.AppendLine(); - } - - return buffer.ToString(); - } - - public static string FormatReactions(IReadOnlyList reactions) - { - if (!reactions.Any()) - return ""; - - var buffer = new StringBuilder(); - - buffer.AppendLine("{Reactions}"); - - foreach (var reaction in reactions) - { - buffer.Append(reaction.Emoji.Name); - - if (reaction.Count > 1) - buffer.Append($" ({reaction.Count})"); - - buffer.Append(" "); - } - - buffer.AppendLine(); - - return buffer.ToString(); - } - - public static string FormatMessage(RenderContext context, Message message) - { - var buffer = new StringBuilder(); - - buffer - .AppendLine(FormatMessageHeader(context, message)) - .AppendLineIfNotEmpty(FormatMessageContent(context, message)) - .AppendLine() - .AppendLineIfNotEmpty(FormatAttachments(message.Attachments)) - .AppendLineIfNotEmpty(FormatEmbeds(context, message.Embeds)) - .AppendLineIfNotEmpty(FormatReactions(message.Reactions)); - - return buffer.Trim().ToString(); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Logic/SharedRenderingLogic.cs b/DiscordChatExporter.Core.Rendering/Logic/SharedRenderingLogic.cs deleted file mode 100644 index 656a0ff..0000000 --- a/DiscordChatExporter.Core.Rendering/Logic/SharedRenderingLogic.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using System.Globalization; - -namespace DiscordChatExporter.Core.Rendering.Logic -{ - public static class SharedRenderingLogic - { - public static string FormatDate(DateTimeOffset date, string dateFormat) => - date.ToLocalTime().ToString(dateFormat, CultureInfo.InvariantCulture); - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/DiscordChatExporter.Core.Services.csproj b/DiscordChatExporter.Core.Services/DiscordChatExporter.Core.Services.csproj deleted file mode 100644 index 58a910e..0000000 --- a/DiscordChatExporter.Core.Services/DiscordChatExporter.Core.Services.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Exceptions/HttpErrorStatusCodeException.cs b/DiscordChatExporter.Core.Services/Exceptions/HttpErrorStatusCodeException.cs deleted file mode 100644 index e3da38e..0000000 --- a/DiscordChatExporter.Core.Services/Exceptions/HttpErrorStatusCodeException.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System; -using System.Net; - -namespace DiscordChatExporter.Core.Services.Exceptions -{ - public class HttpErrorStatusCodeException : Exception - { - public HttpStatusCode StatusCode { get; } - - public string ReasonPhrase { get; } - - public HttpErrorStatusCodeException(HttpStatusCode statusCode, string reasonPhrase) - : base($"Error HTTP status code: {statusCode} - {reasonPhrase}") - { - StatusCode = statusCode; - ReasonPhrase = reasonPhrase; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/ExportService.cs b/DiscordChatExporter.Core.Services/ExportService.cs deleted file mode 100644 index e305b23..0000000 --- a/DiscordChatExporter.Core.Services/ExportService.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Models.Exceptions; -using DiscordChatExporter.Core.Rendering; -using DiscordChatExporter.Core.Services.Logic; -using Tyrrrz.Extensions; - -namespace DiscordChatExporter.Core.Services -{ - public partial class ExportService - { - private readonly SettingsService _settingsService; - private readonly DataService _dataService; - - public ExportService(SettingsService settingsService, DataService dataService) - { - _settingsService = settingsService; - _dataService = dataService; - } - - public async Task ExportChatLogAsync(AuthToken token, Guild guild, Channel channel, - string outputPath, ExportFormat format, int? partitionLimit, - DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress? progress = null) - { - // Get base file path from output path - var baseFilePath = GetFilePathFromOutputPath(outputPath, format, guild, channel, after, before); - - // Create options - var options = new RenderOptions(baseFilePath, format, partitionLimit); - - // Create context - var mentionableUsers = new HashSet(IdBasedEqualityComparer.Instance); - var mentionableChannels = await _dataService.GetGuildChannelsAsync(token, guild.Id); - var mentionableRoles = guild.Roles; - - var context = new RenderContext - ( - guild, channel, after, before, _settingsService.DateFormat, - mentionableUsers, mentionableChannels, mentionableRoles - ); - - // Create renderer - await using var renderer = new MessageRenderer(options, context); - - // Render messages - var renderedAnything = false; - await foreach (var message in _dataService.GetMessagesAsync(token, channel.Id, after, before, progress)) - { - // Add encountered users to the list of mentionable users - var encounteredUsers = new List(); - encounteredUsers.Add(message.Author); - encounteredUsers.AddRange(message.MentionedUsers); - - mentionableUsers.AddRange(encounteredUsers); - - foreach (User u in encounteredUsers) - { - if(!guild.Members.ContainsKey(u.Id)) - { - var member = await _dataService.GetGuildMemberAsync(token, guild.Id, u.Id); - guild.Members[u.Id] = member; - } - } - - - // Render message - await renderer.RenderMessageAsync(message); - renderedAnything = true; - } - - // Throw if no messages were rendered - if (!renderedAnything) - throw new DomainException($"Channel [{channel.Name}] contains no messages for specified period"); - } - } - - public partial class ExportService - { - private static string GetFilePathFromOutputPath(string outputPath, ExportFormat format, Guild guild, Channel channel, - DateTimeOffset? after, DateTimeOffset? before) - { - // Output is a directory - if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) - { - var fileName = ExportLogic.GetDefaultExportFileName(format, guild, channel, after, before); - return Path.Combine(outputPath, fileName); - } - - // Output is a file - return outputPath; - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Internal/Extensions/ColorExtensions.cs b/DiscordChatExporter.Core.Services/Internal/Extensions/ColorExtensions.cs deleted file mode 100644 index b6dd12d..0000000 --- a/DiscordChatExporter.Core.Services/Internal/Extensions/ColorExtensions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.Drawing; - -namespace DiscordChatExporter.Core.Services.Internal.Extensions -{ - internal static class ColorExtensions - { - public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color); - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Internal/Extensions/GenericExtensions.cs b/DiscordChatExporter.Core.Services/Internal/Extensions/GenericExtensions.cs deleted file mode 100644 index db24fe5..0000000 --- a/DiscordChatExporter.Core.Services/Internal/Extensions/GenericExtensions.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace DiscordChatExporter.Core.Services.Internal.Extensions -{ - internal static class GenericExtensions - { - public static TOut Pipe(this TIn input, Func transform) => transform(input); - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Internal/Json.cs b/DiscordChatExporter.Core.Services/Internal/Json.cs deleted file mode 100644 index 00d17da..0000000 --- a/DiscordChatExporter.Core.Services/Internal/Json.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json; - -namespace DiscordChatExporter.Core.Services.Internal -{ - internal static class Json - { - public static JsonElement Parse(string json) - { - using var document = JsonDocument.Parse(json); - return document.RootElement.Clone(); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Logic/ExportLogic.cs b/DiscordChatExporter.Core.Services/Logic/ExportLogic.cs deleted file mode 100644 index e96b13c..0000000 --- a/DiscordChatExporter.Core.Services/Logic/ExportLogic.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.IO; -using System.Text; -using DiscordChatExporter.Core.Models; - -namespace DiscordChatExporter.Core.Services.Logic -{ - public static class ExportLogic - { - public static string GetDefaultExportFileName(ExportFormat format, - Guild guild, Channel channel, - DateTimeOffset? after = null, DateTimeOffset? before = null) - { - var buffer = new StringBuilder(); - - // Append guild and channel names - buffer.Append($"{guild.Name} - {channel.Name} [{channel.Id}]"); - - // Append date range - if (after != null || before != null) - { - buffer.Append(" ("); - - // Both 'after' and 'before' are set - if (after != null && before != null) - { - buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}"); - } - // Only 'after' is set - else if (after != null) - { - buffer.Append($"after {after:yyyy-MM-dd}"); - } - // Only 'before' is set - else - { - buffer.Append($"before {before:yyyy-MM-dd}"); - } - - buffer.Append(")"); - } - - // Append extension - buffer.Append($".{format.GetFileExtension()}"); - - // Replace invalid chars - foreach (var invalidChar in Path.GetInvalidFileNameChars()) - buffer.Replace(invalidChar, '_'); - - return buffer.ToString(); - } - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Extensions.cs b/DiscordChatExporter.Domain/Discord/AccessibilityExtensions.cs similarity index 86% rename from DiscordChatExporter.Core.Services/Extensions.cs rename to DiscordChatExporter.Domain/Discord/AccessibilityExtensions.cs index 673caae..e1dc16b 100644 --- a/DiscordChatExporter.Core.Services/Extensions.cs +++ b/DiscordChatExporter.Domain/Discord/AccessibilityExtensions.cs @@ -2,9 +2,9 @@ using System.Runtime.CompilerServices; using System.Threading.Tasks; -namespace DiscordChatExporter.Core.Services +namespace DiscordChatExporter.Domain.Discord { - public static class Extensions + public static class AccessibilityExtensions { private static async ValueTask> AggregateAsync(this IAsyncEnumerable asyncEnumerable) { diff --git a/DiscordChatExporter.Domain/Discord/AuthToken.cs b/DiscordChatExporter.Domain/Discord/AuthToken.cs new file mode 100644 index 0000000..6edcf83 --- /dev/null +++ b/DiscordChatExporter.Domain/Discord/AuthToken.cs @@ -0,0 +1,29 @@ +using System.Net.Http.Headers; + +namespace DiscordChatExporter.Domain.Discord +{ + public enum AuthTokenType + { + User, + Bot + } + + public class AuthToken + { + public AuthTokenType Type { get; } + + public string Value { get; } + + public AuthToken(AuthTokenType type, string value) + { + Type = type; + Value = value; + } + + public AuthenticationHeaderValue GetAuthenticationHeader() => Type == AuthTokenType.User + ? new AuthenticationHeaderValue(Value) + : new AuthenticationHeaderValue("Bot", Value); + + public override string ToString() => Value; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/DataService.Parsers.cs b/DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs similarity index 92% rename from DiscordChatExporter.Core.Services/DataService.Parsers.cs rename to DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs index a4b5f2e..cc0a6b9 100644 --- a/DiscordChatExporter.Core.Services/DataService.Parsers.cs +++ b/DiscordChatExporter.Domain/Discord/DiscordClient.Parsing.cs @@ -2,13 +2,13 @@ using System.Drawing; using System.Linq; using System.Text.Json; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Services.Internal.Extensions; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Internal; using Tyrrrz.Extensions; -namespace DiscordChatExporter.Core.Services +namespace DiscordChatExporter.Domain.Discord { - public partial class DataService + public partial class DiscordClient { private string ParseId(JsonElement json) => json.GetProperty("id").GetString(); @@ -42,7 +42,7 @@ namespace DiscordChatExporter.Core.Services var roles = json.GetPropertyOrNull("roles")?.EnumerateArray().Select(ParseRole).ToArray() ?? Array.Empty(); - return new Guild(id, name, roles, iconHash); + return new Guild(id, name, iconHash, roles); } private Channel ParseChannel(JsonElement json) @@ -60,14 +60,14 @@ namespace DiscordChatExporter.Core.Services json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(ParseUser).Select(u => u.Name).JoinToString(", ") ?? id; - return new Channel(id, parentId, guildId, name, topic, type); + 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.GetProperty("color").GetInt32().Pipe(Color.FromArgb); + 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); @@ -82,7 +82,7 @@ namespace DiscordChatExporter.Core.Services var fileName = json.GetProperty("filename").GetString(); var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes); - return new Attachment(id, width, height, url, fileName, fileSize); + return new Attachment(id, url, fileName, width, height, fileSize); } private EmbedAuthor ParseEmbedAuthor(JsonElement json) @@ -153,7 +153,7 @@ namespace DiscordChatExporter.Core.Services var count = json.GetProperty("count").GetInt32(); var emoji = json.GetProperty("emoji").Pipe(ParseEmoji); - return new Reaction(count, emoji); + return new Reaction(emoji, count); } private Message ParseMessage(JsonElement json) @@ -183,10 +183,10 @@ namespace DiscordChatExporter.Core.Services Array.Empty(); var embeds = json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(ParseEmbed).ToArray() ?? - Array.Empty(); + Array.Empty(); var reactions = json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(ParseReaction).ToArray() ?? - Array.Empty(); + Array.Empty(); var mentionedUsers = json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(ParseUser).ToArray() ?? Array.Empty(); diff --git a/DiscordChatExporter.Core.Services/DataService.cs b/DiscordChatExporter.Domain/Discord/DiscordClient.cs similarity index 63% rename from DiscordChatExporter.Core.Services/DataService.cs rename to DiscordChatExporter.Domain/Discord/DiscordClient.cs index f9485ba..24b7bbc 100644 --- a/DiscordChatExporter.Core.Services/DataService.cs +++ b/DiscordChatExporter.Domain/Discord/DiscordClient.cs @@ -3,29 +3,29 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; -using System.Net.Http.Headers; using System.Text.Json; using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Services.Exceptions; -using DiscordChatExporter.Core.Services.Internal; -using DiscordChatExporter.Core.Services.Internal.Extensions; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exceptions; +using DiscordChatExporter.Domain.Internal; using Polly; -namespace DiscordChatExporter.Core.Services +namespace DiscordChatExporter.Domain.Discord { - public partial class DataService : IDisposable + public partial class DiscordClient { - private readonly HttpClient _httpClient = new HttpClient(); - private readonly IAsyncPolicy _httpPolicy; + private readonly AuthToken _token; + private readonly HttpClient _httpClient; + private readonly IAsyncPolicy _httpRequestPolicy; - public DataService() + public DiscordClient(AuthToken token, HttpClient httpClient) { - _httpClient.BaseAddress = new Uri("https://discordapp.com/api/v6"); + _token = token; + _httpClient = httpClient; // Discord seems to always respond 429 on our first request with unreasonable wait time (10+ minutes). // For that reason the policy will start respecting their retry-after header only after Nth failed response. - _httpPolicy = Policy + _httpRequestPolicy = Policy .HandleResult(m => m.StatusCode == HttpStatusCode.TooManyRequests) .OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError) .WaitAndRetryAsync(6, @@ -42,64 +42,70 @@ namespace DiscordChatExporter.Core.Services (response, timespan, retryCount, context) => Task.CompletedTask); } - private async Task GetApiResponseAsync(AuthToken token, string route) + public DiscordClient(AuthToken token) + : this(token, LazyHttpClient.Value) { - return (await GetApiResponseAsync(token, route, true))!.Value; } - private async Task GetApiResponseAsync(AuthToken token, string route, bool errorOnFail) + private async Task GetApiResponseAsync(string url) { - using var response = await _httpPolicy.ExecuteAsync(async () => + using var response = await _httpRequestPolicy.ExecuteAsync(async () => { - using var request = new HttpRequestMessage(HttpMethod.Get, route); + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = _token.GetAuthenticationHeader(); - request.Headers.Authorization = token.Type == AuthTokenType.Bot - ? new AuthenticationHeaderValue("Bot", token.Value) - : new AuthenticationHeaderValue(token.Value); - - return await _httpClient.SendAsync(request); + return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); }); - // We throw our own exception here because default one doesn't have status code - if (!response.IsSuccessStatusCode) - { - if (errorOnFail) - throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase); + if (response.StatusCode == HttpStatusCode.Unauthorized) + throw DiscordChatExporterException.Unauthorized(); + + if ((int) response.StatusCode >= 400) + throw DiscordChatExporterException.FailedHttpRequest(response); + + return await response.Content.ReadAsJsonAsync(); + } + // TODO: do we need this? + private async Task TryGetApiResponseAsync(string url) + { + try + { + return await GetApiResponseAsync(url); + } + catch (DiscordChatExporterException) + { return null; } - - var jsonRaw = await response.Content.ReadAsStringAsync(); - return Json.Parse(jsonRaw); } - public async Task GetGuildAsync(AuthToken token, string guildId) + public async Task GetGuildAsync(string guildId) { // Special case for direct messages pseudo-guild if (guildId == Guild.DirectMessages.Id) return Guild.DirectMessages; - var response = await GetApiResponseAsync(token, $"guilds/{guildId}"); + var response = await GetApiResponseAsync($"guilds/{guildId}"); var guild = ParseGuild(response); return guild; } - public async Task GetGuildMemberAsync(AuthToken token, string guildId, string userId) + public async Task GetGuildMemberAsync(string guildId, string userId) { - var response = await GetApiResponseAsync(token, $"guilds/{guildId}/members/{userId}", false); + var response = await TryGetApiResponseAsync($"guilds/{guildId}/members/{userId}"); return response?.Pipe(ParseMember); } - public async Task GetChannelAsync(AuthToken token, string channelId) + public async Task GetChannelAsync(string channelId) { - var response = await GetApiResponseAsync(token, $"channels/{channelId}"); + var response = await GetApiResponseAsync($"channels/{channelId}"); var channel = ParseChannel(response); return channel; } - public async IAsyncEnumerable GetUserGuildsAsync(AuthToken token) + public async IAsyncEnumerable GetUserGuildsAsync() { var afterId = ""; @@ -109,7 +115,7 @@ namespace DiscordChatExporter.Core.Services if (!string.IsNullOrWhiteSpace(afterId)) route += $"&after={afterId}"; - var response = await GetApiResponseAsync(token, route); + var response = await GetApiResponseAsync(route); var isEmpty = true; @@ -118,7 +124,7 @@ namespace DiscordChatExporter.Core.Services { var guildId = ParseId(guildJson); - yield return await GetGuildAsync(token, guildId); + yield return await GetGuildAsync(guildId); afterId = guildId; isEmpty = false; @@ -129,42 +135,42 @@ namespace DiscordChatExporter.Core.Services } } - public async Task> GetDirectMessageChannelsAsync(AuthToken token) + public async Task> GetDirectMessageChannelsAsync() { - var response = await GetApiResponseAsync(token, "users/@me/channels"); + var response = await GetApiResponseAsync("users/@me/channels"); var channels = response.EnumerateArray().Select(ParseChannel).ToArray(); return channels; } - public async Task> GetGuildChannelsAsync(AuthToken token, string guildId) + public async Task> GetGuildChannelsAsync(string guildId) { // Special case for direct messages pseudo-guild if (guildId == Guild.DirectMessages.Id) return Array.Empty(); - var response = await GetApiResponseAsync(token, $"guilds/{guildId}/channels"); + var response = await GetApiResponseAsync($"guilds/{guildId}/channels"); var channels = response.EnumerateArray().Select(ParseChannel).ToArray(); return channels; } - private async Task GetLastMessageAsync(AuthToken token, string channelId, DateTimeOffset? before = null) + private async Task GetLastMessageAsync(string channelId, DateTimeOffset? before = null) { var route = $"channels/{channelId}/messages?limit=1"; if (before != null) route += $"&before={before.Value.ToSnowflake()}"; - var response = await GetApiResponseAsync(token, route); + var response = await GetApiResponseAsync(route); return response.EnumerateArray().Select(ParseMessage).FirstOrDefault(); } - public async IAsyncEnumerable GetMessagesAsync(AuthToken token, string channelId, + public async IAsyncEnumerable GetMessagesAsync(string channelId, DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress? progress = null) { // Get the last message - var lastMessage = await GetLastMessageAsync(token, channelId, before); + var lastMessage = await GetLastMessageAsync(channelId, before); // If the last message doesn't exist or it's outside of range - return if (lastMessage == null || lastMessage.Timestamp < after) @@ -180,7 +186,7 @@ namespace DiscordChatExporter.Core.Services { // Get message batch var route = $"channels/{channelId}/messages?limit=100&after={afterId}"; - var response = await GetApiResponseAsync(token, route); + var response = await GetApiResponseAsync(route); // Parse var messages = response @@ -221,7 +227,23 @@ namespace DiscordChatExporter.Core.Services yield return lastMessage; progress?.Report(1); } + } + + public partial class DiscordClient + { + private static readonly Lazy LazyHttpClient = new Lazy(() => + { + var handler = new HttpClientHandler(); + + if (handler.SupportsAutomaticDecompression) + handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate; - public void Dispose() => _httpClient.Dispose(); + handler.UseCookies = false; + + return new HttpClient(handler, true) + { + BaseAddress = new Uri("https://discordapp.com/api/v6") + }; + }); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Attachment.cs b/DiscordChatExporter.Domain/Discord/Models/Attachment.cs similarity index 54% rename from DiscordChatExporter.Core.Models/Attachment.cs rename to DiscordChatExporter.Domain/Discord/Models/Attachment.cs index 2365e46..593844c 100644 --- a/DiscordChatExporter.Core.Models/Attachment.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Attachment.cs @@ -2,7 +2,7 @@ using System.IO; using System.Linq; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/channel#attachment-object @@ -12,30 +12,26 @@ namespace DiscordChatExporter.Core.Models public string Url { get; } + public string FileName { get; } + public int? Width { get; } public int? Height { get; } - public string FileName { get; } - - public bool IsImage { get; } + public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase); - public bool IsSpoiler { get; } + public bool IsSpoiler => IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal); public FileSize FileSize { get; } - public Attachment(string id, int? width, int? height, string url, string fileName, FileSize fileSize) + public Attachment(string id, string url, string fileName, int? width, int? height, FileSize fileSize) { Id = id; Url = url; + FileName = fileName; Width = width; Height = height; - FileName = fileName; FileSize = fileSize; - - IsImage = GetIsImage(fileName); - - IsSpoiler = IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal); } public override string ToString() => FileName; @@ -43,12 +39,6 @@ namespace DiscordChatExporter.Core.Models public partial class Attachment { - private static readonly string[] ImageFileExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".bmp" }; - - public static bool GetIsImage(string fileName) - { - var fileExtension = Path.GetExtension(fileName); - return ImageFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase); - } + private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"}; } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Discord/Models/Channel.cs b/DiscordChatExporter.Domain/Discord/Models/Channel.cs new file mode 100644 index 0000000..99a4dc6 --- /dev/null +++ b/DiscordChatExporter.Domain/Discord/Models/Channel.cs @@ -0,0 +1,58 @@ +namespace DiscordChatExporter.Domain.Discord.Models +{ + // https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types + // Order of enum fields needs to match the order in the docs. + + public enum ChannelType + { + GuildTextChat, + DirectTextChat, + GuildVoiceChat, + DirectGroupTextChat, + GuildCategory, + GuildNews, + GuildStore + } + + // https://discordapp.com/developers/docs/resources/channel#channel-object + + public partial class Channel : IHasId + { + public string Id { get; } + + public string GuildId { get; } + + public string? ParentId { get; } + + public ChannelType Type { get; } + + public bool IsTextChannel => + Type == ChannelType.GuildTextChat || + Type == ChannelType.DirectTextChat || + Type == ChannelType.DirectGroupTextChat || + Type == ChannelType.GuildNews || + Type == ChannelType.GuildStore; + + public string Name { get; } + + public string? Topic { get; } + + public Channel(string id, string guildId, string? parentId, ChannelType type, string name, string? topic) + { + Id = id; + GuildId = guildId; + ParentId = parentId; + Type = type; + Name = name; + Topic = topic; + } + + public override string ToString() => Name; + } + + public partial class Channel + { + public static Channel CreateDeletedChannel(string id) => + new Channel(id, "unknown-guild", null, ChannelType.GuildTextChat, "deleted-channel", null); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Embed.cs b/DiscordChatExporter.Domain/Discord/Models/Embed.cs similarity index 71% rename from DiscordChatExporter.Core.Models/Embed.cs rename to DiscordChatExporter.Domain/Discord/Models/Embed.cs index dc84d7f..2c46064 100644 --- a/DiscordChatExporter.Core.Models/Embed.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Embed.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using System.Drawing; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/channel#embed-object @@ -28,8 +28,17 @@ namespace DiscordChatExporter.Core.Models public EmbedFooter? Footer { get; } - public Embed(string? title, string? url, DateTimeOffset? timestamp, Color? color, EmbedAuthor? author, string? description, - IReadOnlyList fields, EmbedImage? thumbnail, EmbedImage? image, EmbedFooter? footer) + public Embed( + string? title, + string? url, + DateTimeOffset? timestamp, + Color? color, + EmbedAuthor? author, + string? description, + IReadOnlyList fields, + EmbedImage? thumbnail, + EmbedImage? image, + EmbedFooter? footer) { Title = title; Url = url; diff --git a/DiscordChatExporter.Core.Models/EmbedAuthor.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs similarity index 90% rename from DiscordChatExporter.Core.Models/EmbedAuthor.cs rename to DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs index 659eb28..43b47a9 100644 --- a/DiscordChatExporter.Core.Models/EmbedAuthor.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedAuthor.cs @@ -1,4 +1,4 @@ -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure diff --git a/DiscordChatExporter.Core.Models/EmbedField.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedField.cs similarity index 90% rename from DiscordChatExporter.Core.Models/EmbedField.cs rename to DiscordChatExporter.Domain/Discord/Models/EmbedField.cs index d594b2e..844fe7d 100644 --- a/DiscordChatExporter.Core.Models/EmbedField.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedField.cs @@ -1,4 +1,4 @@ -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure diff --git a/DiscordChatExporter.Core.Models/EmbedFooter.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs similarity index 88% rename from DiscordChatExporter.Core.Models/EmbedFooter.cs rename to DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs index 4c10025..50901da 100644 --- a/DiscordChatExporter.Core.Models/EmbedFooter.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedFooter.cs @@ -1,4 +1,4 @@ -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure diff --git a/DiscordChatExporter.Core.Models/EmbedImage.cs b/DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs similarity index 88% rename from DiscordChatExporter.Core.Models/EmbedImage.cs rename to DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs index c438f90..119eecb 100644 --- a/DiscordChatExporter.Core.Models/EmbedImage.cs +++ b/DiscordChatExporter.Domain/Discord/Models/EmbedImage.cs @@ -1,4 +1,4 @@ -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure diff --git a/DiscordChatExporter.Core.Models/Emoji.cs b/DiscordChatExporter.Domain/Discord/Models/Emoji.cs similarity index 80% rename from DiscordChatExporter.Core.Models/Emoji.cs rename to DiscordChatExporter.Domain/Discord/Models/Emoji.cs index c49ccd9..79b15f4 100644 --- a/DiscordChatExporter.Core.Models/Emoji.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Emoji.cs @@ -3,7 +3,7 @@ using System.Linq; using System.Text; using Tyrrrz.Extensions; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/emoji#emoji-object @@ -25,6 +25,8 @@ namespace DiscordChatExporter.Core.Models ImageUrl = GetImageUrl(id, name, isAnimated); } + + public override string ToString() => Name; } public partial class Emoji @@ -58,17 +60,12 @@ namespace DiscordChatExporter.Core.Models return $"https://cdn.discordapp.com/emojis/{id}.png"; } - // Standard unicode emoji + // Get runes var emojiRunes = GetRunes(name).ToArray(); - if (emojiRunes.Any()) - { - // Get corresponding Twemoji image - var twemojiName = GetTwemojiName(emojiRunes); - return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png"; - } - // Fallback in case of failure - return name; + // Get corresponding Twemoji image + var twemojiName = GetTwemojiName(emojiRunes); + return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png"; } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/FileSize.cs b/DiscordChatExporter.Domain/Discord/Models/FileSize.cs similarity index 97% rename from DiscordChatExporter.Core.Models/FileSize.cs rename to DiscordChatExporter.Domain/Discord/Models/FileSize.cs index 21e2931..de67e3c 100644 --- a/DiscordChatExporter.Core.Models/FileSize.cs +++ b/DiscordChatExporter.Domain/Discord/Models/FileSize.cs @@ -1,6 +1,6 @@ using System; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // Loosely based on https://github.com/omar/ByteSize (MIT license) diff --git a/DiscordChatExporter.Core.Models/Guild.cs b/DiscordChatExporter.Domain/Discord/Models/Guild.cs similarity index 58% rename from DiscordChatExporter.Core.Models/Guild.cs rename to DiscordChatExporter.Domain/Discord/Models/Guild.cs index 67af3d3..8b7e8cb 100644 --- a/DiscordChatExporter.Core.Models/Guild.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Guild.cs @@ -1,9 +1,9 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.Linq; +using DiscordChatExporter.Domain.Internal; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/guild#guild-object @@ -15,21 +15,21 @@ namespace DiscordChatExporter.Core.Models public string? IconHash { get; } + public string IconUrl => !string.IsNullOrWhiteSpace(IconHash) + ? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png" + : "https://cdn.discordapp.com/embed/avatars/0.png"; + public IReadOnlyList Roles { get; } public Dictionary Members { get; } - public string IconUrl { get; } - - public Guild(string id, string name, IReadOnlyList roles, string? iconHash) + public Guild(string id, string name, string? iconHash, IReadOnlyList roles) { Id = id; Name = name; IconHash = iconHash; Roles = roles; Members = new Dictionary(); - - IconUrl = GetIconUrl(id, iconHash); } public override string ToString() => Name; @@ -38,22 +38,17 @@ namespace DiscordChatExporter.Core.Models public partial class Guild { public static string GetUserColor(Guild guild, User user) => - guild.Members.GetValueOrDefault(user.Id, null) - ?.Roles + guild.Members.GetValueOrDefault(user.Id, null)? + .RoleIds .Select(r => guild.Roles.FirstOrDefault(role => r == role.Id)) .Where(r => r != null) - .Where(r => r.Color != Color.Black) - .Where(r => r.Color.R + r.Color.G + r.Color.B > 0) - .Aggregate(null, (a, b) => (a?.Position ?? 0) > b.Position ? a : b) - ?.ColorAsHex ?? ""; + .Where(r => r.Color != null) + .Aggregate(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 string GetIconUrl(string id, string? iconHash) => - !string.IsNullOrWhiteSpace(iconHash) - ? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png" - : "https://cdn.discordapp.com/embed/avatars/0.png"; - - public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", Array.Empty(), null); + public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, Array.Empty()); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/IHasId.cs b/DiscordChatExporter.Domain/Discord/Models/IHasId.cs similarity index 55% rename from DiscordChatExporter.Core.Models/IHasId.cs rename to DiscordChatExporter.Domain/Discord/Models/IHasId.cs index 27ac643..3974abc 100644 --- a/DiscordChatExporter.Core.Models/IHasId.cs +++ b/DiscordChatExporter.Domain/Discord/Models/IHasId.cs @@ -1,4 +1,4 @@ -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { public interface IHasId { diff --git a/DiscordChatExporter.Core.Models/IdBasedEqualityComparer.cs b/DiscordChatExporter.Domain/Discord/Models/IdBasedEqualityComparer.cs similarity index 90% rename from DiscordChatExporter.Core.Models/IdBasedEqualityComparer.cs rename to DiscordChatExporter.Domain/Discord/Models/IdBasedEqualityComparer.cs index 1c77ad9..5c1ed33 100644 --- a/DiscordChatExporter.Core.Models/IdBasedEqualityComparer.cs +++ b/DiscordChatExporter.Domain/Discord/Models/IdBasedEqualityComparer.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { public partial class IdBasedEqualityComparer : IEqualityComparer { diff --git a/DiscordChatExporter.Core.Models/Member.cs b/DiscordChatExporter.Domain/Discord/Models/Member.cs similarity index 70% rename from DiscordChatExporter.Core.Models/Member.cs rename to DiscordChatExporter.Domain/Discord/Models/Member.cs index a0c4074..198a437 100644 --- a/DiscordChatExporter.Core.Models/Member.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Member.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/guild#guild-member-object @@ -10,13 +10,13 @@ namespace DiscordChatExporter.Core.Models public string? Nick { get; } - public IReadOnlyList Roles { get; } + public IReadOnlyList RoleIds { get; } - public Member(string userId, string? nick, IReadOnlyList roles) + public Member(string userId, string? nick, IReadOnlyList roleIds) { UserId = userId; Nick = nick; - Roles = roles; + RoleIds = roleIds; } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Message.cs b/DiscordChatExporter.Domain/Discord/Models/Message.cs similarity index 55% rename from DiscordChatExporter.Core.Models/Message.cs rename to DiscordChatExporter.Domain/Discord/Models/Message.cs index 60fb6e2..c061657 100644 --- a/DiscordChatExporter.Core.Models/Message.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Message.cs @@ -1,8 +1,23 @@ using System; using System.Collections.Generic; +using System.Linq; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { + // https://discordapp.com/developers/docs/resources/channel#message-object-message-types + + public enum MessageType + { + Default, + RecipientAdd, + RecipientRemove, + Call, + ChannelNameChange, + ChannelIconChange, + ChannelPinnedMessage, + GuildMemberJoin + } + // https://discordapp.com/developers/docs/resources/channel#message-object public class Message : IHasId @@ -21,7 +36,7 @@ namespace DiscordChatExporter.Core.Models public bool IsPinned { get; } - public string? Content { get; } + public string Content { get; } public IReadOnlyList Attachments { get; } @@ -31,10 +46,18 @@ namespace DiscordChatExporter.Core.Models public IReadOnlyList MentionedUsers { get; } - public Message(string id, string channelId, MessageType type, User author, - DateTimeOffset timestamp, DateTimeOffset? editedTimestamp, bool isPinned, + public Message( + string id, + string channelId, + MessageType type, + User author, + DateTimeOffset timestamp, + DateTimeOffset? editedTimestamp, + bool isPinned, string content, - IReadOnlyList attachments,IReadOnlyList embeds, IReadOnlyList reactions, + IReadOnlyList attachments, + IReadOnlyList embeds, + IReadOnlyList reactions, IReadOnlyList mentionedUsers) { Id = id; @@ -51,6 +74,9 @@ namespace DiscordChatExporter.Core.Models MentionedUsers = mentionedUsers; } - public override string ToString() => Content ?? ""; + public override string ToString() => + Content ?? (Embeds.Any() + ? "" + : ""); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Reaction.cs b/DiscordChatExporter.Domain/Discord/Models/Reaction.cs similarity index 60% rename from DiscordChatExporter.Core.Models/Reaction.cs rename to DiscordChatExporter.Domain/Discord/Models/Reaction.cs index c10f7fd..a960008 100644 --- a/DiscordChatExporter.Core.Models/Reaction.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Reaction.cs @@ -1,17 +1,19 @@ -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/channel#reaction-object public class Reaction { - public int Count { get; } - public Emoji Emoji { get; } - public Reaction(int count, Emoji emoji) + public int Count { get; } + + public Reaction(Emoji emoji, int count) { - Count = count; Emoji = emoji; + Count = count; } + + public override string ToString() => $"{Emoji} ({Count})"; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/Role.cs b/DiscordChatExporter.Domain/Discord/Models/Role.cs similarity index 62% rename from DiscordChatExporter.Core.Models/Role.cs rename to DiscordChatExporter.Domain/Discord/Models/Role.cs index 2a3a6a5..d1bcdcf 100644 --- a/DiscordChatExporter.Core.Models/Role.cs +++ b/DiscordChatExporter.Domain/Discord/Models/Role.cs @@ -1,6 +1,6 @@ using System.Drawing; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/topics/permissions#role-object @@ -10,15 +10,11 @@ namespace DiscordChatExporter.Core.Models public string Name { get; } - public Color Color { get; } - - public string ColorAsHex => $"#{Color.ToArgb() & 0xffffff:X6}"; - - public string ColorAsRgb => $"{Color.R}, {Color.G}, {Color.B}"; + public Color? Color { get; } public int Position { get; } - public Role(string id, string name, Color color, int position) + public Role(string id, string name, Color? color, int position) { Id = id; Name = name; @@ -31,6 +27,6 @@ namespace DiscordChatExporter.Core.Models public partial class Role { - public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role", Color.Black, -1); + public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role", null, -1); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Models/User.cs b/DiscordChatExporter.Domain/Discord/Models/User.cs similarity index 81% rename from DiscordChatExporter.Core.Models/User.cs rename to DiscordChatExporter.Domain/Discord/Models/User.cs index dc116f7..df1a035 100644 --- a/DiscordChatExporter.Core.Models/User.cs +++ b/DiscordChatExporter.Domain/Discord/Models/User.cs @@ -1,6 +1,6 @@ using System; -namespace DiscordChatExporter.Core.Models +namespace DiscordChatExporter.Domain.Discord.Models { // https://discordapp.com/developers/docs/resources/user#user-object @@ -12,7 +12,7 @@ namespace DiscordChatExporter.Core.Models public string Name { get; } - public string FullName { get; } + public string FullName => $"{Name}#{Discriminator:0000}"; public string? AvatarHash { get; } @@ -28,7 +28,6 @@ namespace DiscordChatExporter.Core.Models AvatarHash = avatarHash; IsBot = isBot; - FullName = GetFullName(name, discriminator); AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash); } @@ -37,9 +36,7 @@ namespace DiscordChatExporter.Core.Models public partial class User { - public static string GetFullName(string name, int discriminator) => $"{name}#{discriminator:0000}"; - - public static string GetAvatarUrl(string id, int discriminator, string? avatarHash) + private static string GetAvatarUrl(string id, int discriminator, string? avatarHash) { // Custom avatar if (!string.IsNullOrWhiteSpace(avatarHash)) diff --git a/DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj b/DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj new file mode 100644 index 0000000..efd3b46 --- /dev/null +++ b/DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/DiscordChatExporter.Domain/Exceptions/DiscordChatExporterException.cs b/DiscordChatExporter.Domain/Exceptions/DiscordChatExporterException.cs new file mode 100644 index 0000000..7576941 --- /dev/null +++ b/DiscordChatExporter.Domain/Exceptions/DiscordChatExporterException.cs @@ -0,0 +1,59 @@ +using System; +using System.Net.Http; +using DiscordChatExporter.Domain.Discord.Models; + +namespace DiscordChatExporter.Domain.Exceptions +{ + public partial class DiscordChatExporterException : Exception + { + public bool IsCritical { get; } + + public DiscordChatExporterException(string message, bool isCritical = false) + : base(message) + { + IsCritical = isCritical; + } + } + + public partial class DiscordChatExporterException + { + internal static DiscordChatExporterException FailedHttpRequest(HttpResponseMessage response) + { + var message = $@" +Failed to perform an HTTP request. + +{response.RequestMessage} + +{response}"; + + return new DiscordChatExporterException(message.Trim(), true); + } + + internal static DiscordChatExporterException Unauthorized() + { + const string message = "Authentication token is invalid."; + return new DiscordChatExporterException(message); + } + + internal static DiscordChatExporterException ChannelForbidden(string channel) + { + var message = $"Access to channel '{channel}' is forbidden."; + return new DiscordChatExporterException(message); + } + + internal static DiscordChatExporterException ChannelDoesNotExist(string channel) + { + var message = $"Channel '{channel}' does not exist."; + return new DiscordChatExporterException(message); + } + + internal static DiscordChatExporterException ChannelEmpty(string channel) + { + var message = $"Channel '{channel}' contains no messages for the specified period."; + return new DiscordChatExporterException(message); + } + + internal static DiscordChatExporterException ChannelEmpty(Channel channel) => + ChannelEmpty(channel.Name); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/ExportFormat.cs b/DiscordChatExporter.Domain/Exporting/ExportFormat.cs new file mode 100644 index 0000000..692ed99 --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/ExportFormat.cs @@ -0,0 +1,36 @@ +using System; + +namespace DiscordChatExporter.Domain.Exporting +{ + public enum ExportFormat + { + PlainText, + HtmlDark, + HtmlLight, + Csv, + Json + } + + public static class ExportFormatExtensions + { + public static string GetFileExtension(this ExportFormat format) => format switch + { + ExportFormat.PlainText => "txt", + ExportFormat.HtmlDark => "html", + ExportFormat.HtmlLight => "html", + ExportFormat.Csv => "csv", + ExportFormat.Json => "json", + _ => throw new ArgumentOutOfRangeException(nameof(format)) + }; + + public static string GetDisplayName(this ExportFormat format) => format switch + { + ExportFormat.PlainText => "TXT", + ExportFormat.HtmlDark => "HTML (Dark)", + ExportFormat.HtmlLight => "HTML (Light)", + ExportFormat.Csv => "CSV", + ExportFormat.Json => "JSON", + _ => throw new ArgumentOutOfRangeException(nameof(format)) + }; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/Exporter.cs b/DiscordChatExporter.Domain/Exporting/Exporter.cs new file mode 100644 index 0000000..0fbfe8d --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/Exporter.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using DiscordChatExporter.Domain.Discord; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exceptions; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Domain.Exporting +{ + public partial class Exporter + { + private readonly DiscordClient _discord; + + public Exporter(DiscordClient discord) => _discord = discord; + + public async Task ExportChatLogAsync(Guild guild, Channel channel, + string outputPath, ExportFormat format, string dateFormat, int? partitionLimit, + DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress? progress = null) + { + // Get base file path from output path + var baseFilePath = GetFilePathFromOutputPath(outputPath, format, guild, channel, after, before); + + // Create options + var options = new RenderOptions(baseFilePath, format, partitionLimit); + + // Create context + var mentionableUsers = new HashSet(IdBasedEqualityComparer.Instance); + var mentionableChannels = await _discord.GetGuildChannelsAsync(guild.Id); + var mentionableRoles = guild.Roles; + + var context = new RenderContext + ( + guild, channel, after, before, dateFormat, + mentionableUsers, mentionableChannels, mentionableRoles + ); + + // Create renderer + await using var renderer = new MessageRenderer(options, context); + + // Render messages + var renderedAnything = false; + await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress)) + { + // Add encountered users to the list of mentionable users + var encounteredUsers = new List(); + encounteredUsers.Add(message.Author); + encounteredUsers.AddRange(message.MentionedUsers); + + mentionableUsers.AddRange(encounteredUsers); + + foreach (User u in encounteredUsers) + { + if(!guild.Members.ContainsKey(u.Id)) + { + var member = await _discord.GetGuildMemberAsync(guild.Id, u.Id); + guild.Members[u.Id] = member; + } + } + + + // Render message + await renderer.RenderMessageAsync(message); + renderedAnything = true; + } + + // Throw if no messages were rendered + if (!renderedAnything) + throw DiscordChatExporterException.ChannelEmpty(channel); + } + } + + public partial class Exporter + { + public static string GetDefaultExportFileName(ExportFormat format, + Guild guild, Channel channel, + DateTimeOffset? after = null, DateTimeOffset? before = null) + { + var buffer = new StringBuilder(); + + // Append guild and channel names + buffer.Append($"{guild.Name} - {channel.Name} [{channel.Id}]"); + + // Append date range + if (after != null || before != null) + { + buffer.Append(" ("); + + // Both 'after' and 'before' are set + if (after != null && before != null) + { + buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}"); + } + // Only 'after' is set + else if (after != null) + { + buffer.Append($"after {after:yyyy-MM-dd}"); + } + // Only 'before' is set + else + { + buffer.Append($"before {before:yyyy-MM-dd}"); + } + + buffer.Append(")"); + } + + // Append extension + buffer.Append($".{format.GetFileExtension()}"); + + // Replace invalid chars + foreach (var invalidChar in Path.GetInvalidFileNameChars()) + buffer.Replace(invalidChar, '_'); + + return buffer.ToString(); + } + + private static string GetFilePathFromOutputPath(string outputPath, ExportFormat format, Guild guild, Channel channel, + DateTimeOffset? after, DateTimeOffset? before) + { + // Output is a directory + if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) + { + var fileName = GetDefaultExportFileName(format, guild, channel, after, before); + return Path.Combine(outputPath, fileName); + } + + // Output is a file + return outputPath; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/MessageGroup.cs b/DiscordChatExporter.Domain/Exporting/MessageGroup.cs new file mode 100644 index 0000000..c2e4c9c --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/MessageGroup.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using DiscordChatExporter.Domain.Discord.Models; + +namespace DiscordChatExporter.Domain.Exporting +{ + // Used for grouping contiguous messages in HTML export + + internal partial class MessageGroup + { + public User Author { get; } + + public DateTimeOffset Timestamp { get; } + + public IReadOnlyList Messages { get; } + + public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList messages) + { + Author = author; + Timestamp = timestamp; + Messages = messages; + } + } + + internal partial class MessageGroup + { + public static bool CanGroup(Message message1, Message message2) => + string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal) && + string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) && + (message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/MessageRenderer.cs b/DiscordChatExporter.Domain/Exporting/MessageRenderer.cs similarity index 71% rename from DiscordChatExporter.Core.Rendering/MessageRenderer.cs rename to DiscordChatExporter.Domain/Exporting/MessageRenderer.cs index 8519a60..6a0e212 100644 --- a/DiscordChatExporter.Core.Rendering/MessageRenderer.cs +++ b/DiscordChatExporter.Domain/Exporting/MessageRenderer.cs @@ -1,12 +1,12 @@ using System; using System.IO; using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Rendering.Formatters; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exporting.Writers; -namespace DiscordChatExporter.Core.Rendering +namespace DiscordChatExporter.Domain.Exporting { - public partial class MessageRenderer : IAsyncDisposable + internal partial class MessageRenderer : IAsyncDisposable { private readonly RenderOptions _options; private readonly RenderContext _context; @@ -21,7 +21,7 @@ namespace DiscordChatExporter.Core.Rendering _context = context; } - private async Task InitializeWriterAsync() + private async Task InitializeWriterAsync() { // Get partition file path var filePath = GetPartitionFilePath(_options.BaseFilePath, _partitionIndex); @@ -32,10 +32,12 @@ namespace DiscordChatExporter.Core.Rendering Directory.CreateDirectory(dirPath); // Create writer - _writer = CreateMessageWriter(filePath, _options.Format, _context); + var writer = CreateMessageWriter(filePath, _options.Format, _context); // Write preamble - await _writer.WritePreambleAsync(); + await writer.WritePreambleAsync(); + + return _writer = writer; } private async Task ResetWriterAsync() @@ -54,8 +56,7 @@ namespace DiscordChatExporter.Core.Rendering public async Task RenderMessageAsync(Message message) { // Ensure underlying writer is initialized - if (_writer == null) - await InitializeWriterAsync(); + _writer ??= await InitializeWriterAsync(); // Render the actual message await _writer!.WriteMessageAsync(message); @@ -76,7 +77,7 @@ namespace DiscordChatExporter.Core.Rendering public async ValueTask DisposeAsync() => await ResetWriterAsync(); } - public partial class MessageRenderer + internal partial class MessageRenderer { private static string GetPartitionFilePath(string baseFilePath, int partitionIndex) { @@ -102,23 +103,15 @@ namespace DiscordChatExporter.Core.Rendering // Create a stream (it will get disposed by the writer) var stream = File.Create(filePath); - // Create formatter - if (format == ExportFormat.PlainText) - return new PlainTextMessageWriter(stream, context); - - if (format == ExportFormat.Csv) - return new CsvMessageWriter(stream, context); - - if (format == ExportFormat.HtmlDark) - return new HtmlMessageWriter(stream, context, "Dark"); - - if (format == ExportFormat.HtmlLight) - return new HtmlMessageWriter(stream, context, "Light"); - - if (format == ExportFormat.Json) - return new JsonMessageWriter(stream, context); - - throw new InvalidOperationException($"Unknown export format [{format}]."); + return format switch + { + ExportFormat.PlainText => new PlainTextMessageWriter(stream, context), + ExportFormat.Csv => new CsvMessageWriter(stream, context), + ExportFormat.HtmlDark => new HtmlMessageWriter(stream, context, "Dark"), + ExportFormat.HtmlLight => new HtmlMessageWriter(stream, context, "Light"), + ExportFormat.Json => new JsonMessageWriter(stream, context), + _ => throw new ArgumentOutOfRangeException(nameof(format), $"Unknown export format '{format}'.") + }; } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/RenderContext.cs b/DiscordChatExporter.Domain/Exporting/RenderContext.cs similarity index 64% rename from DiscordChatExporter.Core.Rendering/RenderContext.cs rename to DiscordChatExporter.Domain/Exporting/RenderContext.cs index c20750e..e4ee94f 100644 --- a/DiscordChatExporter.Core.Rendering/RenderContext.cs +++ b/DiscordChatExporter.Domain/Exporting/RenderContext.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Domain.Discord.Models; -namespace DiscordChatExporter.Core.Rendering +namespace DiscordChatExporter.Domain.Exporting { public class RenderContext { @@ -22,8 +22,16 @@ namespace DiscordChatExporter.Core.Rendering public IReadOnlyCollection MentionableRoles { get; } - public RenderContext(Guild guild, Channel channel, DateTimeOffset? after, DateTimeOffset? before, string dateFormat, - IReadOnlyCollection mentionableUsers, IReadOnlyCollection mentionableChannels, IReadOnlyCollection mentionableRoles) + public RenderContext( + Guild guild, + Channel channel, + DateTimeOffset? after, + DateTimeOffset? before, + string dateFormat, + IReadOnlyCollection mentionableUsers, + IReadOnlyCollection mentionableChannels, + IReadOnlyCollection mentionableRoles) + { Guild = guild; Channel = channel; diff --git a/DiscordChatExporter.Core.Rendering/RenderOptions.cs b/DiscordChatExporter.Domain/Exporting/RenderOptions.cs similarity index 82% rename from DiscordChatExporter.Core.Rendering/RenderOptions.cs rename to DiscordChatExporter.Domain/Exporting/RenderOptions.cs index 37d09d4..94ad7d0 100644 --- a/DiscordChatExporter.Core.Rendering/RenderOptions.cs +++ b/DiscordChatExporter.Domain/Exporting/RenderOptions.cs @@ -1,6 +1,4 @@ -using DiscordChatExporter.Core.Models; - -namespace DiscordChatExporter.Core.Rendering +namespace DiscordChatExporter.Domain.Exporting { public class RenderOptions { diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlCore.css b/DiscordChatExporter.Domain/Exporting/Resources/HtmlCore.css similarity index 100% rename from DiscordChatExporter.Core.Rendering/Resources/HtmlCore.css rename to DiscordChatExporter.Domain/Exporting/Resources/HtmlCore.css diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlDark.css b/DiscordChatExporter.Domain/Exporting/Resources/HtmlDark.css similarity index 100% rename from DiscordChatExporter.Core.Rendering/Resources/HtmlDark.css rename to DiscordChatExporter.Domain/Exporting/Resources/HtmlDark.css diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlLayoutTemplate.html b/DiscordChatExporter.Domain/Exporting/Resources/HtmlLayoutTemplate.html similarity index 100% rename from DiscordChatExporter.Core.Rendering/Resources/HtmlLayoutTemplate.html rename to DiscordChatExporter.Domain/Exporting/Resources/HtmlLayoutTemplate.html diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlLight.css b/DiscordChatExporter.Domain/Exporting/Resources/HtmlLight.css similarity index 100% rename from DiscordChatExporter.Core.Rendering/Resources/HtmlLight.css rename to DiscordChatExporter.Domain/Exporting/Resources/HtmlLight.css diff --git a/DiscordChatExporter.Core.Rendering/Resources/HtmlMessageGroupTemplate.html b/DiscordChatExporter.Domain/Exporting/Resources/HtmlMessageGroupTemplate.html similarity index 97% rename from DiscordChatExporter.Core.Rendering/Resources/HtmlMessageGroupTemplate.html rename to DiscordChatExporter.Domain/Exporting/Resources/HtmlMessageGroupTemplate.html index 52cae23..342b534 100644 --- a/DiscordChatExporter.Core.Rendering/Resources/HtmlMessageGroupTemplate.html +++ b/DiscordChatExporter.Domain/Exporting/Resources/HtmlMessageGroupTemplate.html @@ -5,7 +5,7 @@
{{~ # Author name and timestamp ~}} - {{ GetUserNick Context.Guild MessageGroup.Author | html.escape }} + {{ GetUserNick Context.Guild MessageGroup.Author | html.escape }} {{~ # Bot tag ~}} {{~ if MessageGroup.Author.IsBot ~}} diff --git a/DiscordChatExporter.Domain/Exporting/Writers/CsvMessageWriter.cs b/DiscordChatExporter.Domain/Exporting/Writers/CsvMessageWriter.cs new file mode 100644 index 0000000..28adb2b --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/Writers/CsvMessageWriter.cs @@ -0,0 +1,58 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors; +using DiscordChatExporter.Domain.Internal; +using Tyrrrz.Extensions; + +namespace DiscordChatExporter.Domain.Exporting.Writers +{ + internal class CsvMessageWriter : MessageWriterBase + { + private readonly TextWriter _writer; + + public CsvMessageWriter(Stream stream, RenderContext context) + : base(stream, context) + { + _writer = new StreamWriter(stream); + } + + private string EncodeValue(string value) + { + value = value.Replace("\"", "\"\""); + return $"\"{value}\""; + } + + private string FormatMarkdown(string markdown) => + PlainTextMarkdownVisitor.Format(Context, markdown); + + private string FormatMessage(Message message) + { + var buffer = new StringBuilder(); + + buffer + .Append(EncodeValue(message.Author.Id)).Append(',') + .Append(EncodeValue(message.Author.FullName)).Append(',') + .Append(EncodeValue(message.Timestamp.ToLocalString(Context.DateFormat))).Append(',') + .Append(EncodeValue(FormatMarkdown(message.Content))).Append(',') + .Append(EncodeValue(message.Attachments.Select(a => a.Url).JoinToString(","))).Append(',') + .Append(EncodeValue(message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(","))); + + return buffer.ToString(); + } + + public override async Task WritePreambleAsync() => + await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions"); + + public override async Task WriteMessageAsync(Message message) => + await _writer.WriteLineAsync(FormatMessage(message)); + + public override async ValueTask DisposeAsync() + { + await _writer.DisposeAsync(); + await base.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Formatters/HtmlMessageWriter.cs b/DiscordChatExporter.Domain/Exporting/Writers/HtmlMessageWriter.cs similarity index 84% rename from DiscordChatExporter.Core.Rendering/Formatters/HtmlMessageWriter.cs rename to DiscordChatExporter.Domain/Exporting/Writers/HtmlMessageWriter.cs index f180870..92b1e76 100644 --- a/DiscordChatExporter.Core.Rendering/Formatters/HtmlMessageWriter.cs +++ b/DiscordChatExporter.Domain/Exporting/Writers/HtmlMessageWriter.cs @@ -1,18 +1,24 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.IO; using System.Linq; +using System.Net; using System.Reflection; +using System.Text.RegularExpressions; using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Rendering.Logic; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors; +using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Markdown; +using DiscordChatExporter.Domain.Markdown.Ast; using Scriban; using Scriban.Runtime; using Tyrrrz.Extensions; -namespace DiscordChatExporter.Core.Rendering.Formatters +namespace DiscordChatExporter.Domain.Exporting.Writers { - public partial class HtmlMessageWriter : MessageWriterBase + internal partial class HtmlMessageWriter : MessageWriterBase { private readonly TextWriter _writer; private readonly string _themeName; @@ -70,13 +76,13 @@ namespace DiscordChatExporter.Core.Rendering.Formatters // Functions scriptObject.Import("FormatDate", - new Func(d => SharedRenderingLogic.FormatDate(d, Context.DateFormat))); + new Func(d => d.ToLocalString(Context.DateFormat))); scriptObject.Import("FormatMarkdown", - new Func(m => HtmlRenderingLogic.FormatMarkdown(Context, m))); + new Func(FormatMarkdown)); scriptObject.Import("GetUserColor", new Func(Guild.GetUserColor)); - + scriptObject.Import("GetUserNick", new Func(Guild.GetUserNick)); // Push model @@ -88,6 +94,9 @@ namespace DiscordChatExporter.Core.Rendering.Formatters return templateContext; } + private string FormatMarkdown(string markdown) => + HtmlMarkdownVisitor.Format(Context, markdown); + private async Task RenderCurrentMessageGroupAsync() { var templateContext = CreateTemplateContext(new Dictionary @@ -107,7 +116,7 @@ namespace DiscordChatExporter.Core.Rendering.Formatters public override async Task WriteMessageAsync(Message message) { // If message group is empty or the given message can be grouped, buffer the given message - if (!_messageGroupBuffer.Any() || HtmlRenderingLogic.CanBeGrouped(_messageGroupBuffer.Last(), message)) + if (!_messageGroupBuffer.Any() || MessageGroup.CanGroup(_messageGroupBuffer.Last(), message)) { _messageGroupBuffer.Add(message); } @@ -145,10 +154,10 @@ namespace DiscordChatExporter.Core.Rendering.Formatters } } - public partial class HtmlMessageWriter + internal partial class HtmlMessageWriter { - private static readonly Assembly ResourcesAssembly = typeof(HtmlRenderingLogic).Assembly; - private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Resources"; + private static readonly Assembly ResourcesAssembly = typeof(HtmlMessageWriter).Assembly; + private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Exporting.Resources"; private static string GetCoreStyleSheetCode() => ResourcesAssembly @@ -171,5 +180,7 @@ namespace DiscordChatExporter.Core.Rendering.Formatters ResourcesAssembly .GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html") .SubstringAfter("{{~ %SPLIT% ~}}"); + + private static string HtmlEncode(string s) => WebUtility.HtmlEncode(s); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Formatters/JsonMessageWriter.cs b/DiscordChatExporter.Domain/Exporting/Writers/JsonMessageWriter.cs similarity index 95% rename from DiscordChatExporter.Core.Rendering/Formatters/JsonMessageWriter.cs rename to DiscordChatExporter.Domain/Exporting/Writers/JsonMessageWriter.cs index 6c89856..8d8af5c 100644 --- a/DiscordChatExporter.Core.Rendering/Formatters/JsonMessageWriter.cs +++ b/DiscordChatExporter.Domain/Exporting/Writers/JsonMessageWriter.cs @@ -1,13 +1,13 @@ using System.IO; using System.Text.Json; using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Rendering.Internal; -using DiscordChatExporter.Core.Rendering.Logic; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors; +using DiscordChatExporter.Domain.Internal; -namespace DiscordChatExporter.Core.Rendering.Formatters +namespace DiscordChatExporter.Domain.Exporting.Writers { - public class JsonMessageWriter : MessageWriterBase + internal class JsonMessageWriter : MessageWriterBase { private readonly Utf8JsonWriter _writer; @@ -66,7 +66,7 @@ namespace DiscordChatExporter.Core.Rendering.Formatters _writer.WriteBoolean("isPinned", message.IsPinned); // Content - var content = PlainTextRenderingLogic.FormatMessageContent(Context, message); + var content = PlainTextMarkdownVisitor.Format(Context, message.Content); _writer.WriteString("content", content); // Author diff --git a/DiscordChatExporter.Domain/Exporting/Writers/MarkdownVisitors/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Domain/Exporting/Writers/MarkdownVisitors/HtmlMarkdownVisitor.cs new file mode 100644 index 0000000..ca1bd92 --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/Writers/MarkdownVisitors/HtmlMarkdownVisitor.cs @@ -0,0 +1,177 @@ +using System; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Internal; +using DiscordChatExporter.Domain.Markdown; +using DiscordChatExporter.Domain.Markdown.Ast; + +namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors +{ + internal partial class HtmlMarkdownVisitor : MarkdownVisitor + { + private readonly RenderContext _context; + private readonly StringBuilder _buffer; + private readonly bool _isJumbo; + + public HtmlMarkdownVisitor(RenderContext context, StringBuilder buffer, bool isJumbo) + { + _context = context; + _buffer = buffer; + _isJumbo = isJumbo; + } + + public override MarkdownNode VisitText(TextNode text) + { + _buffer.Append(HtmlEncode(text.Text)); + return base.VisitText(text); + } + + public override MarkdownNode VisitFormatted(FormattedNode formatted) + { + var (tagOpen, tagClose) = formatted.Formatting switch + { + TextFormatting.Bold => ("", ""), + TextFormatting.Italic => ("", ""), + TextFormatting.Underline => ("", ""), + TextFormatting.Strikethrough => ("", ""), + TextFormatting.Spoiler => ( + "", ""), + TextFormatting.Quote => ("
", "
"), + _ => throw new ArgumentOutOfRangeException(nameof(formatted.Formatting)) + }; + + _buffer.Append(tagOpen); + var result = base.VisitFormatted(formatted); + _buffer.Append(tagClose); + + return result; + } + + public override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) + { + _buffer + .Append("") + .Append(HtmlEncode(inlineCodeBlock.Code)) + .Append(""); + + return base.VisitInlineCodeBlock(inlineCodeBlock); + } + + public override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) + { + var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language) + ? $"language-{multiLineCodeBlock.Language}" + : "nohighlight"; + + _buffer + .Append($"
") + .Append(HtmlEncode(multiLineCodeBlock.Code)) + .Append("
"); + + return base.VisitMultiLineCodeBlock(multiLineCodeBlock); + } + + public override MarkdownNode VisitMention(MentionNode mention) + { + if (mention.Type == MentionType.Meta) + { + _buffer + .Append("") + .Append("@").Append(HtmlEncode(mention.Id)) + .Append(""); + } + else if (mention.Type == MentionType.User) + { + var user = _context.MentionableUsers.FirstOrDefault(u => u.Id == mention.Id) ?? + User.CreateUnknownUser(mention.Id); + + var nick = Guild.GetUserNick(_context.Guild, user); + + _buffer + .Append($"") + .Append("@").Append(HtmlEncode(nick)) + .Append(""); + } + else if (mention.Type == MentionType.Channel) + { + var channel = _context.MentionableChannels.FirstOrDefault(c => c.Id == mention.Id) ?? + Channel.CreateDeletedChannel(mention.Id); + + _buffer + .Append("") + .Append("#").Append(HtmlEncode(channel.Name)) + .Append(""); + } + else if (mention.Type == MentionType.Role) + { + var role = _context.MentionableRoles.FirstOrDefault(r => r.Id == mention.Id) ?? + Role.CreateDeletedRole(mention.Id); + + var style = role.Color != null + ? $"color: {role.Color.Value.ToHexString()}; background-color: rgba({role.Color.Value.ToRgbString()}, 0.1);" + : ""; + + _buffer + .Append($"\"") + .Append("@").Append(HtmlEncode(role.Name)) + .Append(""); + } + + return base.VisitMention(mention); + } + + public override MarkdownNode VisitEmoji(EmojiNode emoji) + { + var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); + var jumboClass = _isJumbo ? "emoji--large" : ""; + + _buffer + .Append($"\"{emoji.Name}\""); + + return base.VisitEmoji(emoji); + } + + public override MarkdownNode VisitLink(LinkNode link) + { + // Extract message ID if the link points to a Discord message + var linkedMessageId = Regex.Match(link.Url, "^https?://discordapp.com/channels/.*?/(\\d+)/?$").Groups[1].Value; + + if (!string.IsNullOrWhiteSpace(linkedMessageId)) + { + _buffer + .Append($"") + .Append(HtmlEncode(link.Title)) + .Append(""); + } + else + { + _buffer + .Append($"") + .Append(HtmlEncode(link.Title)) + .Append(""); + } + + return base.VisitLink(link); + } + } + + internal partial class HtmlMarkdownVisitor + { + private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text); + + public static string Format(RenderContext context, string markdown) + { + var nodes = MarkdownParser.Parse(markdown); + var isJumbo = nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)); + + var buffer = new StringBuilder(); + + new HtmlMarkdownVisitor(context, buffer, isJumbo).Visit(nodes); + + return buffer.ToString(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Exporting/Writers/MarkdownVisitors/PlainTextMarkdownVisitor.cs b/DiscordChatExporter.Domain/Exporting/Writers/MarkdownVisitors/PlainTextMarkdownVisitor.cs new file mode 100644 index 0000000..fab8ee5 --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/Writers/MarkdownVisitors/PlainTextMarkdownVisitor.cs @@ -0,0 +1,75 @@ +using System.Linq; +using System.Text; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Markdown; +using DiscordChatExporter.Domain.Markdown.Ast; + +namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors +{ + internal partial class PlainTextMarkdownVisitor : MarkdownVisitor + { + private readonly RenderContext _context; + private readonly StringBuilder _buffer; + + public PlainTextMarkdownVisitor(RenderContext context, StringBuilder buffer) + { + _context = context; + _buffer = buffer; + } + + public override MarkdownNode VisitText(TextNode text) + { + _buffer.Append(text.Text); + return base.VisitText(text); + } + + public override MarkdownNode VisitMention(MentionNode mention) + { + if (mention.Type == MentionType.User) + { + var user = _context.MentionableUsers.FirstOrDefault(u => u.Id == mention.Id) ?? + User.CreateUnknownUser(mention.Id); + + _buffer.Append($"@{user.Name}"); + } + else if (mention.Type == MentionType.Channel) + { + var channel = _context.MentionableChannels.FirstOrDefault(c => c.Id == mention.Id) ?? + Channel.CreateDeletedChannel(mention.Id); + + _buffer.Append($"#{channel.Name}"); + } + else if (mention.Type == MentionType.Role) + { + var role = _context.MentionableRoles.FirstOrDefault(r => r.Id == mention.Id) ?? + Role.CreateDeletedRole(mention.Id); + + _buffer.Append($"@{role.Name}"); + } + + return base.VisitMention(mention); + } + + public override MarkdownNode VisitEmoji(EmojiNode emoji) + { + _buffer.Append(emoji.IsCustomEmoji + ? $":{emoji.Name}:" + : emoji.Name); + + return base.VisitEmoji(emoji); + } + } + + internal partial class PlainTextMarkdownVisitor + { + public static string Format(RenderContext context, string markdown) + { + var nodes = MarkdownParser.ParseMinimal(markdown); + var buffer = new StringBuilder(); + + new PlainTextMarkdownVisitor(context, buffer).Visit(nodes); + + return buffer.ToString(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Formatters/MessageWriterBase.cs b/DiscordChatExporter.Domain/Exporting/Writers/MessageWriterBase.cs similarity index 78% rename from DiscordChatExporter.Core.Rendering/Formatters/MessageWriterBase.cs rename to DiscordChatExporter.Domain/Exporting/Writers/MessageWriterBase.cs index b637cb6..34f3fee 100644 --- a/DiscordChatExporter.Core.Rendering/Formatters/MessageWriterBase.cs +++ b/DiscordChatExporter.Domain/Exporting/Writers/MessageWriterBase.cs @@ -1,11 +1,11 @@ using System; using System.IO; using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Domain.Discord.Models; -namespace DiscordChatExporter.Core.Rendering.Formatters +namespace DiscordChatExporter.Domain.Exporting.Writers { - public abstract class MessageWriterBase : IAsyncDisposable + internal abstract class MessageWriterBase : IAsyncDisposable { protected Stream Stream { get; } diff --git a/DiscordChatExporter.Domain/Exporting/Writers/PlainTextMessageWriter.cs b/DiscordChatExporter.Domain/Exporting/Writers/PlainTextMessageWriter.cs new file mode 100644 index 0000000..d9c405a --- /dev/null +++ b/DiscordChatExporter.Domain/Exporting/Writers/PlainTextMessageWriter.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors; +using DiscordChatExporter.Domain.Internal; + +namespace DiscordChatExporter.Domain.Exporting.Writers +{ + internal class PlainTextMessageWriter : MessageWriterBase + { + private readonly TextWriter _writer; + + private long _messageCount; + + public PlainTextMessageWriter(Stream stream, RenderContext context) + : base(stream, context) + { + _writer = new StreamWriter(stream); + } + + private string FormatPreamble() + { + var buffer = new StringBuilder(); + + buffer.Append('=', 62).AppendLine(); + buffer.AppendLine($"Guild: {Context.Guild.Name}"); + buffer.AppendLine($"Channel: {Context.Channel.Name}"); + + if (!string.IsNullOrWhiteSpace(Context.Channel.Topic)) + buffer.AppendLine($"Topic: {Context.Channel.Topic}"); + + if (Context.After != null) + buffer.AppendLine($"After: {Context.After.Value.ToLocalString(Context.DateFormat)}"); + + if (Context.Before != null) + buffer.AppendLine($"Before: {Context.Before.Value.ToLocalString(Context.DateFormat)}"); + + buffer.Append('=', 62).AppendLine(); + + return buffer.ToString(); + } + + private string FormatPostamble() + { + var buffer = new StringBuilder(); + + buffer.Append('=', 62).AppendLine(); + buffer.AppendLine($"Exported {_messageCount:N0} message(s)"); + buffer.Append('=', 62).AppendLine(); + + return buffer.ToString(); + } + + private string FormatMarkdown(string markdown) => + PlainTextMarkdownVisitor.Format(Context, markdown); + + private string FormatMessageHeader(Message message) + { + var buffer = new StringBuilder(); + + // Timestamp & author + buffer + .Append($"[{message.Timestamp.ToLocalString(Context.DateFormat)}]") + .Append(' ') + .Append($"{message.Author.FullName}"); + + // Whether the message is pinned + if (message.IsPinned) + { + buffer.Append(' ').Append("(pinned)"); + } + + return buffer.ToString(); + } + + private string FormatMessageContent(Message message) + { + if (string.IsNullOrWhiteSpace(message.Content)) + return ""; + + return FormatMarkdown(message.Content); + } + + private string FormatAttachments(IReadOnlyList attachments) + { + if (!attachments.Any()) + return ""; + + var buffer = new StringBuilder(); + + buffer + .AppendLine("{Attachments}") + .AppendJoin(Environment.NewLine, attachments.Select(a => a.Url)) + .AppendLine(); + + return buffer.ToString(); + } + + private string FormatEmbeds(IReadOnlyList embeds) + { + if (!embeds.Any()) + return ""; + + var buffer = new StringBuilder(); + + foreach (var embed in embeds) + { + buffer.AppendLine("{Embed}"); + + // Author name + if (!string.IsNullOrWhiteSpace(embed.Author?.Name)) + buffer.AppendLine(embed.Author.Name); + + // URL + if (!string.IsNullOrWhiteSpace(embed.Url)) + buffer.AppendLine(embed.Url); + + // Title + if (!string.IsNullOrWhiteSpace(embed.Title)) + buffer.AppendLine(FormatMarkdown(embed.Title)); + + // Description + if (!string.IsNullOrWhiteSpace(embed.Description)) + buffer.AppendLine(FormatMarkdown(embed.Description)); + + // Fields + foreach (var field in embed.Fields) + { + // Name + if (!string.IsNullOrWhiteSpace(field.Name)) + buffer.AppendLine(field.Name); + + // Value + if (!string.IsNullOrWhiteSpace(field.Value)) + buffer.AppendLine(field.Value); + } + + // Thumbnail URL + if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url)) + buffer.AppendLine(embed.Thumbnail?.Url); + + // Image URL + if (!string.IsNullOrWhiteSpace(embed.Image?.Url)) + buffer.AppendLine(embed.Image?.Url); + + // Footer text + if (!string.IsNullOrWhiteSpace(embed.Footer?.Text)) + buffer.AppendLine(embed.Footer?.Text); + + buffer.AppendLine(); + } + + return buffer.ToString(); + } + + private string FormatReactions(IReadOnlyList reactions) + { + if (!reactions.Any()) + return ""; + + var buffer = new StringBuilder(); + + buffer.AppendLine("{Reactions}"); + + foreach (var reaction in reactions) + { + buffer.Append(reaction.Emoji.Name); + + if (reaction.Count > 1) + buffer.Append($" ({reaction.Count})"); + + buffer.Append(" "); + } + + buffer.AppendLine(); + + return buffer.ToString(); + } + + private string FormatMessage(Message message) + { + var buffer = new StringBuilder(); + + buffer + .AppendLine(FormatMessageHeader(message)) + .AppendLineIfNotEmpty(FormatMessageContent(message)) + .AppendLine() + .AppendLineIfNotEmpty(FormatAttachments(message.Attachments)) + .AppendLineIfNotEmpty(FormatEmbeds(message.Embeds)) + .AppendLineIfNotEmpty(FormatReactions(message.Reactions)); + + return buffer.Trim().ToString(); + } + + public override async Task WritePreambleAsync() + { + await _writer.WriteLineAsync(FormatPreamble()); + } + + public override async Task WriteMessageAsync(Message message) + { + await _writer.WriteLineAsync(FormatMessage(message)); + await _writer.WriteLineAsync(); + + _messageCount++; + } + + public override async Task WritePostambleAsync() + { + await _writer.WriteLineAsync(); + await _writer.WriteLineAsync(FormatPostamble()); + } + + public override async ValueTask DisposeAsync() + { + await _writer.DisposeAsync(); + await base.DisposeAsync(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Internal/ColorExtensions.cs b/DiscordChatExporter.Domain/Internal/ColorExtensions.cs new file mode 100644 index 0000000..8f3a97f --- /dev/null +++ b/DiscordChatExporter.Domain/Internal/ColorExtensions.cs @@ -0,0 +1,15 @@ +using System.Drawing; + +namespace DiscordChatExporter.Domain.Internal +{ + internal static class ColorExtensions + { + public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color); + + 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}"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Internal/Extensions/DateExtensions.cs b/DiscordChatExporter.Domain/Internal/DateExtensions.cs similarity index 54% rename from DiscordChatExporter.Core.Services/Internal/Extensions/DateExtensions.cs rename to DiscordChatExporter.Domain/Internal/DateExtensions.cs index 2e11cb7..cb926ab 100644 --- a/DiscordChatExporter.Core.Services/Internal/Extensions/DateExtensions.cs +++ b/DiscordChatExporter.Domain/Internal/DateExtensions.cs @@ -1,6 +1,7 @@ using System; +using System.Globalization; -namespace DiscordChatExporter.Core.Services.Internal.Extensions +namespace DiscordChatExporter.Domain.Internal { internal static class DateExtensions { @@ -9,5 +10,8 @@ namespace DiscordChatExporter.Core.Services.Internal.Extensions var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22; return value.ToString(); } + + public static string ToLocalString(this DateTimeOffset dateTime, string format) => + dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture); } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Internal/GenericExtensions.cs b/DiscordChatExporter.Domain/Internal/GenericExtensions.cs new file mode 100644 index 0000000..a98f6f4 --- /dev/null +++ b/DiscordChatExporter.Domain/Internal/GenericExtensions.cs @@ -0,0 +1,14 @@ +using System; + +namespace DiscordChatExporter.Domain.Internal +{ + internal static class GenericExtensions + { + public static TOut Pipe(this TIn input, Func transform) => transform(input); + + public static T? NullIf(this T value, Func predicate) where T : struct => + !predicate(value) + ? value + : (T?) null; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Internal/HttpClientExtensions.cs b/DiscordChatExporter.Domain/Internal/HttpClientExtensions.cs new file mode 100644 index 0000000..ce617d5 --- /dev/null +++ b/DiscordChatExporter.Domain/Internal/HttpClientExtensions.cs @@ -0,0 +1,17 @@ +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; + +namespace DiscordChatExporter.Domain.Internal +{ + internal static class HttpClientExtensions + { + public static async Task ReadAsJsonAsync(this HttpContent content) + { + await using var stream = await content.ReadAsStreamAsync(); + using var doc = await JsonDocument.ParseAsync(stream); + + return doc.RootElement.Clone(); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Internal/Extensions/JsonElementExtensions.cs b/DiscordChatExporter.Domain/Internal/JsonElementExtensions.cs similarity index 84% rename from DiscordChatExporter.Core.Services/Internal/Extensions/JsonElementExtensions.cs rename to DiscordChatExporter.Domain/Internal/JsonElementExtensions.cs index 8e19b7f..89d1b6a 100644 --- a/DiscordChatExporter.Core.Services/Internal/Extensions/JsonElementExtensions.cs +++ b/DiscordChatExporter.Domain/Internal/JsonElementExtensions.cs @@ -1,6 +1,6 @@ using System.Text.Json; -namespace DiscordChatExporter.Core.Services.Internal.Extensions +namespace DiscordChatExporter.Domain.Internal { internal static class JsonElementExtensions { diff --git a/DiscordChatExporter.Domain/Internal/StringExtensions.cs b/DiscordChatExporter.Domain/Internal/StringExtensions.cs new file mode 100644 index 0000000..e3fa32e --- /dev/null +++ b/DiscordChatExporter.Domain/Internal/StringExtensions.cs @@ -0,0 +1,21 @@ +using System.Text; + +namespace DiscordChatExporter.Domain.Internal +{ + internal static class StringExtensions + { + public static StringBuilder AppendLineIfNotEmpty(this StringBuilder builder, string value) => + !string.IsNullOrWhiteSpace(value) ? builder.AppendLine(value) : builder; + + public static StringBuilder Trim(this StringBuilder builder) + { + while (builder.Length > 0 && char.IsWhiteSpace(builder[0])) + builder.Remove(0, 1); + + while (builder.Length > 0 && char.IsWhiteSpace(builder[^1])) + builder.Remove(builder.Length - 1, 1); + + return builder; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/Internal/Extensions.cs b/DiscordChatExporter.Domain/Internal/Utf8JsonWriterExtensions.cs similarity index 51% rename from DiscordChatExporter.Core.Rendering/Internal/Extensions.cs rename to DiscordChatExporter.Domain/Internal/Utf8JsonWriterExtensions.cs index 1d8e1e7..ca7557b 100644 --- a/DiscordChatExporter.Core.Rendering/Internal/Extensions.cs +++ b/DiscordChatExporter.Domain/Internal/Utf8JsonWriterExtensions.cs @@ -1,25 +1,10 @@ using System; -using System.Text; using System.Text.Json; -namespace DiscordChatExporter.Core.Rendering.Internal +namespace DiscordChatExporter.Domain.Internal { - internal static class Extensions + internal static class Utf8JsonWriterExtensions { - public static StringBuilder AppendLineIfNotEmpty(this StringBuilder builder, string value) => - !string.IsNullOrWhiteSpace(value) ? builder.AppendLine(value) : builder; - - public static StringBuilder Trim(this StringBuilder builder) - { - while (builder.Length > 0 && char.IsWhiteSpace(builder[0])) - builder.Remove(0, 1); - - while (builder.Length > 0 && char.IsWhiteSpace(builder[^1])) - builder.Remove(builder.Length - 1, 1); - - return builder; - } - public static void WriteString(this Utf8JsonWriter writer, string propertyName, DateTimeOffset? value) { writer.WritePropertyName(propertyName); diff --git a/DiscordChatExporter.Core.Markdown/Ast/EmojiNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/EmojiNode.cs similarity index 84% rename from DiscordChatExporter.Core.Markdown/Ast/EmojiNode.cs rename to DiscordChatExporter.Domain/Markdown/Ast/EmojiNode.cs index f0d8b0c..647a502 100644 --- a/DiscordChatExporter.Core.Markdown/Ast/EmojiNode.cs +++ b/DiscordChatExporter.Domain/Markdown/Ast/EmojiNode.cs @@ -1,6 +1,6 @@ -namespace DiscordChatExporter.Core.Markdown.Ast +namespace DiscordChatExporter.Domain.Markdown.Ast { - public class EmojiNode : Node + internal class EmojiNode : MarkdownNode { public string? Id { get; } diff --git a/DiscordChatExporter.Domain/Markdown/Ast/FormattedNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/FormattedNode.cs new file mode 100644 index 0000000..dbeee1e --- /dev/null +++ b/DiscordChatExporter.Domain/Markdown/Ast/FormattedNode.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace DiscordChatExporter.Domain.Markdown.Ast +{ + internal enum TextFormatting + { + Bold, + Italic, + Underline, + Strikethrough, + Spoiler, + Quote + } + + internal class FormattedNode : MarkdownNode + { + public TextFormatting Formatting { get; } + + public IReadOnlyList Children { get; } + + public FormattedNode(TextFormatting formatting, IReadOnlyList children) + { + Formatting = formatting; + Children = children; + } + + public override string ToString() => $"<{Formatting}> (+{Children.Count})"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Ast/InlineCodeBlockNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/InlineCodeBlockNode.cs similarity index 65% rename from DiscordChatExporter.Core.Markdown/Ast/InlineCodeBlockNode.cs rename to DiscordChatExporter.Domain/Markdown/Ast/InlineCodeBlockNode.cs index e68f8a4..8530e03 100644 --- a/DiscordChatExporter.Core.Markdown/Ast/InlineCodeBlockNode.cs +++ b/DiscordChatExporter.Domain/Markdown/Ast/InlineCodeBlockNode.cs @@ -1,6 +1,6 @@ -namespace DiscordChatExporter.Core.Markdown.Ast +namespace DiscordChatExporter.Domain.Markdown.Ast { - public class InlineCodeBlockNode : Node + internal class InlineCodeBlockNode : MarkdownNode { public string Code { get; } diff --git a/DiscordChatExporter.Core.Markdown/Ast/LinkNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/LinkNode.cs similarity index 78% rename from DiscordChatExporter.Core.Markdown/Ast/LinkNode.cs rename to DiscordChatExporter.Domain/Markdown/Ast/LinkNode.cs index c04e9fe..1a6fcfa 100644 --- a/DiscordChatExporter.Core.Markdown/Ast/LinkNode.cs +++ b/DiscordChatExporter.Domain/Markdown/Ast/LinkNode.cs @@ -1,6 +1,6 @@ -namespace DiscordChatExporter.Core.Markdown.Ast +namespace DiscordChatExporter.Domain.Markdown.Ast { - public class LinkNode : Node + internal class LinkNode : MarkdownNode { public string Url { get; } diff --git a/DiscordChatExporter.Domain/Markdown/Ast/MarkdownNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/MarkdownNode.cs new file mode 100644 index 0000000..eae58be --- /dev/null +++ b/DiscordChatExporter.Domain/Markdown/Ast/MarkdownNode.cs @@ -0,0 +1,6 @@ +namespace DiscordChatExporter.Domain.Markdown.Ast +{ + internal abstract class MarkdownNode + { + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Ast/MentionNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/MentionNode.cs similarity index 58% rename from DiscordChatExporter.Core.Markdown/Ast/MentionNode.cs rename to DiscordChatExporter.Domain/Markdown/Ast/MentionNode.cs index f532e80..fff0491 100644 --- a/DiscordChatExporter.Core.Markdown/Ast/MentionNode.cs +++ b/DiscordChatExporter.Domain/Markdown/Ast/MentionNode.cs @@ -1,6 +1,14 @@ -namespace DiscordChatExporter.Core.Markdown.Ast +namespace DiscordChatExporter.Domain.Markdown.Ast { - public class MentionNode : Node + internal enum MentionType + { + Meta, + User, + Channel, + Role + } + + internal class MentionNode : MarkdownNode { public string Id { get; } diff --git a/DiscordChatExporter.Core.Markdown/Ast/MultiLineCodeBlockNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/MultiLineCodeBlockNode.cs similarity index 57% rename from DiscordChatExporter.Core.Markdown/Ast/MultiLineCodeBlockNode.cs rename to DiscordChatExporter.Domain/Markdown/Ast/MultiLineCodeBlockNode.cs index 8ecd976..cb3d053 100644 --- a/DiscordChatExporter.Core.Markdown/Ast/MultiLineCodeBlockNode.cs +++ b/DiscordChatExporter.Domain/Markdown/Ast/MultiLineCodeBlockNode.cs @@ -1,6 +1,6 @@ -namespace DiscordChatExporter.Core.Markdown.Ast +namespace DiscordChatExporter.Domain.Markdown.Ast { - public class MultiLineCodeBlockNode : Node + internal class MultiLineCodeBlockNode : MarkdownNode { public string Language { get; } @@ -12,6 +12,6 @@ Code = code; } - public override string ToString() => $" {Code}"; + public override string ToString() => $"<{Language}> {Code}"; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Ast/TextNode.cs b/DiscordChatExporter.Domain/Markdown/Ast/TextNode.cs similarity index 65% rename from DiscordChatExporter.Core.Markdown/Ast/TextNode.cs rename to DiscordChatExporter.Domain/Markdown/Ast/TextNode.cs index 6a4a53f..ead23d0 100644 --- a/DiscordChatExporter.Core.Markdown/Ast/TextNode.cs +++ b/DiscordChatExporter.Domain/Markdown/Ast/TextNode.cs @@ -1,6 +1,6 @@ -namespace DiscordChatExporter.Core.Markdown.Ast +namespace DiscordChatExporter.Domain.Markdown.Ast { - public class TextNode : Node + internal class TextNode : MarkdownNode { public string Text { get; } diff --git a/DiscordChatExporter.Core.Markdown/MarkdownParser.cs b/DiscordChatExporter.Domain/Markdown/MarkdownParser.cs similarity index 68% rename from DiscordChatExporter.Core.Markdown/MarkdownParser.cs rename to DiscordChatExporter.Domain/Markdown/MarkdownParser.cs index 484630d..af500a6 100644 --- a/DiscordChatExporter.Core.Markdown/MarkdownParser.cs +++ b/DiscordChatExporter.Domain/Markdown/MarkdownParser.cs @@ -1,70 +1,70 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; -using DiscordChatExporter.Core.Markdown.Ast; -using DiscordChatExporter.Core.Markdown.Internal; +using DiscordChatExporter.Domain.Markdown.Ast; +using DiscordChatExporter.Domain.Markdown.Matching; -namespace DiscordChatExporter.Core.Markdown +namespace DiscordChatExporter.Domain.Markdown { // The following parsing logic is meant to replicate Discord's markdown grammar as close as possible - public static class MarkdownParser + internal static partial class MarkdownParser { private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline; /* Formatting */ // Capture any character until the earliest double asterisk not followed by an asterisk - private static readonly IMatcher BoldFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher BoldFormattedNodeMatcher = new RegexMatcher( new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Bold, Parse(p.Slice(m.Groups[1])))); // Capture any character until the earliest single asterisk not preceded or followed by an asterisk // Opening asterisk must not be followed by whitespace // Closing asterisk must not be preceded by whitespace - private static readonly IMatcher ItalicFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher ItalicFormattedNodeMatcher = new RegexMatcher( new Regex("\\*(?!\\s)(.+?)(? new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1])))); // Capture any character until the earliest triple asterisk not followed by an asterisk - private static readonly IMatcher ItalicBoldFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher ItalicBoldFormattedNodeMatcher = new RegexMatcher( new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattedNodeMatcher))); // Capture any character except underscore until an underscore // Closing underscore must not be followed by a word character - private static readonly IMatcher ItalicAltFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher ItalicAltFormattedNodeMatcher = new RegexMatcher( new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1])))); // Capture any character until the earliest double underscore not followed by an underscore - private static readonly IMatcher UnderlineFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher UnderlineFormattedNodeMatcher = new RegexMatcher( new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Underline, Parse(p.Slice(m.Groups[1])))); // Capture any character until the earliest triple underscore not followed by an underscore - private static readonly IMatcher ItalicUnderlineFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher ItalicUnderlineFormattedNodeMatcher = new RegexMatcher( new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), UnderlineFormattedNodeMatcher))); // Capture any character until the earliest double tilde - private static readonly IMatcher StrikethroughFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher StrikethroughFormattedNodeMatcher = new RegexMatcher( new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Strikethrough, Parse(p.Slice(m.Groups[1])))); // Capture any character until the earliest double pipe - private static readonly IMatcher SpoilerFormattedNodeMatcher = new RegexMatcher( + private static readonly IMatcher SpoilerFormattedNodeMatcher = new RegexMatcher( new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Spoiler, Parse(p.Slice(m.Groups[1])))); // Capture any character until the end of the line // Opening 'greater than' character must be followed by whitespace - private static readonly IMatcher SingleLineQuoteNodeMatcher = new RegexMatcher( + private static readonly IMatcher SingleLineQuoteNodeMatcher = new RegexMatcher( new Regex("^>\\s(.+\n?)", DefaultRegexOptions), (p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1])))); // Repeatedly capture any character until the end of the line // This one is tricky as it ends up producing multiple separate captures which need to be joined - private static readonly IMatcher RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher( + private static readonly IMatcher RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher( new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions), (p, m) => { @@ -74,7 +74,7 @@ namespace DiscordChatExporter.Core.Markdown // Capture any character until the end of the input // Opening 'greater than' characters must be followed by whitespace - private static readonly IMatcher MultiLineQuoteNodeMatcher = new RegexMatcher( + private static readonly IMatcher MultiLineQuoteNodeMatcher = new RegexMatcher( new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline), (p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1])))); @@ -82,41 +82,42 @@ namespace DiscordChatExporter.Core.Markdown // Capture any character except backtick until a backtick // Blank lines at the beginning and end of content are trimmed - private static readonly IMatcher InlineCodeBlockNodeMatcher = new RegexMatcher( + // There can be either one or two backticks, but equal number on both sides + private static readonly IMatcher InlineCodeBlockNodeMatcher = new RegexMatcher( new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline), m => new InlineCodeBlockNode(m.Groups[1].Value.Trim('\r', '\n'))); // Capture language identifier and then any character until the earliest triple backtick // Language identifier is one word immediately after opening backticks, followed immediately by newline // Blank lines at the beginning and end of content are trimmed - private static readonly IMatcher MultiLineCodeBlockNodeMatcher = new RegexMatcher( + private static readonly IMatcher MultiLineCodeBlockNodeMatcher = new RegexMatcher( new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))); /* Mentions */ // Capture @everyone - private static readonly IMatcher EveryoneMentionNodeMatcher = new StringMatcher( + private static readonly IMatcher EveryoneMentionNodeMatcher = new StringMatcher( "@everyone", p => new MentionNode("everyone", MentionType.Meta)); // Capture @here - private static readonly IMatcher HereMentionNodeMatcher = new StringMatcher( + private static readonly IMatcher HereMentionNodeMatcher = new StringMatcher( "@here", p => new MentionNode("here", MentionType.Meta)); // Capture <@123456> or <@!123456> - private static readonly IMatcher UserMentionNodeMatcher = new RegexMatcher( + private static readonly IMatcher UserMentionNodeMatcher = new RegexMatcher( new Regex("<@!?(\\d+)>", DefaultRegexOptions), m => new MentionNode(m.Groups[1].Value, MentionType.User)); // Capture <#123456> - private static readonly IMatcher ChannelMentionNodeMatcher = new RegexMatcher( + private static readonly IMatcher ChannelMentionNodeMatcher = new RegexMatcher( new Regex("<#(\\d+)>", DefaultRegexOptions), m => new MentionNode(m.Groups[1].Value, MentionType.Channel)); // Capture <@&123456> - private static readonly IMatcher RoleMentionNodeMatcher = new RegexMatcher( + private static readonly IMatcher RoleMentionNodeMatcher = new RegexMatcher( new Regex("<@&(\\d+)>", DefaultRegexOptions), m => new MentionNode(m.Groups[1].Value, MentionType.Role)); @@ -127,29 +128,29 @@ namespace DiscordChatExporter.Core.Markdown // ... or surrogate pair // ... or digit followed by enclosing mark // (this does not match all emojis in Discord but it's reasonably accurate enough) - private static readonly IMatcher StandardEmojiNodeMatcher = new RegexMatcher( + private static readonly IMatcher StandardEmojiNodeMatcher = new RegexMatcher( new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions), m => new EmojiNode(m.Groups[1].Value)); // Capture <:lul:123456> or - private static readonly IMatcher CustomEmojiNodeMatcher = new RegexMatcher( + private static readonly IMatcher CustomEmojiNodeMatcher = new RegexMatcher( new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions), m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))); /* Links */ // Capture [title](link) - private static readonly IMatcher TitledLinkNodeMatcher = new RegexMatcher( + private static readonly IMatcher TitledLinkNodeMatcher = new RegexMatcher( new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions), m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value)); // Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace - private static readonly IMatcher AutoLinkNodeMatcher = new RegexMatcher( + private static readonly IMatcher AutoLinkNodeMatcher = new RegexMatcher( new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions), m => new LinkNode(m.Groups[1].Value)); // Same as auto link but also surrounded by angular brackets - private static readonly IMatcher HiddenLinkNodeMatcher = new RegexMatcher( + private static readonly IMatcher HiddenLinkNodeMatcher = new RegexMatcher( new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions), m => new LinkNode(m.Groups[1].Value)); @@ -157,31 +158,31 @@ namespace DiscordChatExporter.Core.Markdown // Capture the shrug emoticon // This escapes it from matching for formatting - private static readonly IMatcher ShrugTextNodeMatcher = new StringMatcher( + private static readonly IMatcher ShrugTextNodeMatcher = new StringMatcher( @"¯\_(ツ)_/¯", p => new TextNode(p.ToString())); // Capture some specific emojis that don't get rendered // This escapes it from matching for emoji - private static readonly IMatcher IgnoredEmojiTextNodeMatcher = new RegexMatcher( + private static readonly IMatcher IgnoredEmojiTextNodeMatcher = new RegexMatcher( new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions), m => new TextNode(m.Groups[1].Value)); // Capture any "symbol/other" character or surrogate pair preceded by a backslash // This escapes it from matching for emoji - private static readonly IMatcher EscapedSymbolTextNodeMatcher = new RegexMatcher( + private static readonly IMatcher EscapedSymbolTextNodeMatcher = new RegexMatcher( new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions), m => new TextNode(m.Groups[1].Value)); // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash // This escapes it from matching for formatting or other tokens - private static readonly IMatcher EscapedCharacterTextNodeMatcher = new RegexMatcher( + private static readonly IMatcher EscapedCharacterTextNodeMatcher = new RegexMatcher( new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions), m => new TextNode(m.Groups[1].Value)); // Combine all matchers into one // Matchers that have similar patterns are ordered from most specific to least specific - private static readonly IMatcher AggregateNodeMatcher = new AggregateMatcher( + private static readonly IMatcher AggregateNodeMatcher = new AggregateMatcher( // Escaped text ShrugTextNodeMatcher, IgnoredEmojiTextNodeMatcher, @@ -223,7 +224,7 @@ namespace DiscordChatExporter.Core.Markdown ); // Minimal set of matchers for non-multimedia formats (e.g. plain text) - private static readonly IMatcher MinimalAggregateNodeMatcher = new AggregateMatcher( + private static readonly IMatcher MinimalAggregateNodeMatcher = new AggregateMatcher( // Mentions EveryoneMentionNodeMatcher, HereMentionNodeMatcher, @@ -235,15 +236,21 @@ namespace DiscordChatExporter.Core.Markdown CustomEmojiNodeMatcher ); - private static IReadOnlyList Parse(StringPart stringPart, IMatcher matcher) => - matcher.MatchAll(stringPart, p => new TextNode(p.ToString())).Select(r => r.Value).ToArray(); + private static IReadOnlyList Parse(StringPart stringPart, IMatcher matcher) => + matcher + .MatchAll(stringPart, p => new TextNode(p.ToString())) + .Select(r => r.Value) + .ToArray(); + } - private static IReadOnlyList Parse(StringPart stringPart) => Parse(stringPart, AggregateNodeMatcher); + internal static partial class MarkdownParser + { + private static IReadOnlyList Parse(StringPart stringPart) => Parse(stringPart, AggregateNodeMatcher); - private static IReadOnlyList ParseMinimal(StringPart stringPart) => Parse(stringPart, MinimalAggregateNodeMatcher); + private static IReadOnlyList ParseMinimal(StringPart stringPart) => Parse(stringPart, MinimalAggregateNodeMatcher); - public static IReadOnlyList Parse(string input) => Parse(new StringPart(input)); + public static IReadOnlyList Parse(string input) => Parse(new StringPart(input)); - public static IReadOnlyList ParseMinimal(string input) => ParseMinimal(new StringPart(input)); + public static IReadOnlyList ParseMinimal(string input) => ParseMinimal(new StringPart(input)); } } \ No newline at end of file diff --git a/DiscordChatExporter.Domain/Markdown/MarkdownVisitor.cs b/DiscordChatExporter.Domain/Markdown/MarkdownVisitor.cs new file mode 100644 index 0000000..69abcba --- /dev/null +++ b/DiscordChatExporter.Domain/Markdown/MarkdownVisitor.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using DiscordChatExporter.Domain.Markdown.Ast; + +namespace DiscordChatExporter.Domain.Markdown +{ + internal abstract class MarkdownVisitor + { + public virtual MarkdownNode VisitText(TextNode text) => text; + + public virtual MarkdownNode VisitFormatted(FormattedNode formatted) + { + Visit(formatted.Children); + return formatted; + } + + public virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => inlineCodeBlock; + + public virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => multiLineCodeBlock; + + public virtual MarkdownNode VisitLink(LinkNode link) => link; + + public virtual MarkdownNode VisitEmoji(EmojiNode emoji) => emoji; + + public virtual MarkdownNode VisitMention(MentionNode mention) => mention; + + public MarkdownNode Visit(MarkdownNode node) => node switch + { + TextNode text => VisitText(text), + FormattedNode formatted => VisitFormatted(formatted), + InlineCodeBlockNode inlineCodeBlock => VisitInlineCodeBlock(inlineCodeBlock), + MultiLineCodeBlockNode multiLineCodeBlock => VisitMultiLineCodeBlock(multiLineCodeBlock), + LinkNode link => VisitLink(link), + EmojiNode emoji => VisitEmoji(emoji), + MentionNode mention => VisitMention(mention), + _ => throw new ArgumentOutOfRangeException(nameof(node)) + }; + + public void Visit(IEnumerable nodes) + { + foreach (var node in nodes) + Visit(node); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Internal/AggregateMatcher.cs b/DiscordChatExporter.Domain/Markdown/Matching/AggregateMatcher.cs similarity index 84% rename from DiscordChatExporter.Core.Markdown/Internal/AggregateMatcher.cs rename to DiscordChatExporter.Domain/Markdown/Matching/AggregateMatcher.cs index fe02d9f..1664469 100644 --- a/DiscordChatExporter.Core.Markdown/Internal/AggregateMatcher.cs +++ b/DiscordChatExporter.Domain/Markdown/Matching/AggregateMatcher.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace DiscordChatExporter.Core.Markdown.Internal +namespace DiscordChatExporter.Domain.Markdown.Matching { internal class AggregateMatcher : IMatcher { @@ -12,11 +12,11 @@ namespace DiscordChatExporter.Core.Markdown.Internal } public AggregateMatcher(params IMatcher[] matchers) - : this((IReadOnlyList>)matchers) + : this((IReadOnlyList>) matchers) { } - public ParsedMatch? Match(StringPart stringPart) + public ParsedMatch? TryMatch(StringPart stringPart) { ParsedMatch? earliestMatch = null; @@ -24,7 +24,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal foreach (var matcher in _matchers) { // Try to match - var match = matcher.Match(stringPart); + var match = matcher.TryMatch(stringPart); // If there's no match - continue if (match == null) diff --git a/DiscordChatExporter.Core.Markdown/Internal/Extensions.cs b/DiscordChatExporter.Domain/Markdown/Matching/IMatcher.cs similarity index 71% rename from DiscordChatExporter.Core.Markdown/Internal/Extensions.cs rename to DiscordChatExporter.Domain/Markdown/Matching/IMatcher.cs index 2aac614..9315b21 100644 --- a/DiscordChatExporter.Core.Markdown/Internal/Extensions.cs +++ b/DiscordChatExporter.Domain/Markdown/Matching/IMatcher.cs @@ -1,19 +1,24 @@ using System; using System.Collections.Generic; -namespace DiscordChatExporter.Core.Markdown.Internal +namespace DiscordChatExporter.Domain.Markdown.Matching { - internal static class Extensions + internal interface IMatcher { - public static IEnumerable> MatchAll(this IMatcher matcher, StringPart stringPart, - Func fallbackTransform) + ParsedMatch? TryMatch(StringPart stringPart); + } + + internal static class MatcherExtensions + { + public static IEnumerable> MatchAll(this IMatcher matcher, + StringPart stringPart, Func transformFallback) { // Loop through segments divided by individual matches var currentIndex = stringPart.StartIndex; while (currentIndex < stringPart.EndIndex) { // Find a match within this segment - var match = matcher.Match(stringPart.Slice(currentIndex, stringPart.EndIndex - currentIndex)); + var match = matcher.TryMatch(stringPart.Slice(currentIndex, stringPart.EndIndex - currentIndex)); // If there's no match - break if (match == null) @@ -23,7 +28,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal if (match.StringPart.StartIndex > currentIndex) { var fallbackPart = stringPart.Slice(currentIndex, match.StringPart.StartIndex - currentIndex); - yield return new ParsedMatch(fallbackPart, fallbackTransform(fallbackPart)); + yield return new ParsedMatch(fallbackPart, transformFallback(fallbackPart)); } // Yield match @@ -37,7 +42,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal if (currentIndex < stringPart.EndIndex) { var fallbackPart = stringPart.Slice(currentIndex); - yield return new ParsedMatch(fallbackPart, fallbackTransform(fallbackPart)); + yield return new ParsedMatch(fallbackPart, transformFallback(fallbackPart)); } } } diff --git a/DiscordChatExporter.Core.Markdown/Internal/ParsedMatch.cs b/DiscordChatExporter.Domain/Markdown/Matching/ParsedMatch.cs similarity index 82% rename from DiscordChatExporter.Core.Markdown/Internal/ParsedMatch.cs rename to DiscordChatExporter.Domain/Markdown/Matching/ParsedMatch.cs index 8ebc32e..3392185 100644 --- a/DiscordChatExporter.Core.Markdown/Internal/ParsedMatch.cs +++ b/DiscordChatExporter.Domain/Markdown/Matching/ParsedMatch.cs @@ -1,4 +1,4 @@ -namespace DiscordChatExporter.Core.Markdown.Internal +namespace DiscordChatExporter.Domain.Markdown.Matching { internal class ParsedMatch { diff --git a/DiscordChatExporter.Core.Markdown/Internal/RegexMatcher.cs b/DiscordChatExporter.Domain/Markdown/Matching/RegexMatcher.cs similarity index 92% rename from DiscordChatExporter.Core.Markdown/Internal/RegexMatcher.cs rename to DiscordChatExporter.Domain/Markdown/Matching/RegexMatcher.cs index 324eba0..3e9d75b 100644 --- a/DiscordChatExporter.Core.Markdown/Internal/RegexMatcher.cs +++ b/DiscordChatExporter.Domain/Markdown/Matching/RegexMatcher.cs @@ -1,7 +1,7 @@ using System; using System.Text.RegularExpressions; -namespace DiscordChatExporter.Core.Markdown.Internal +namespace DiscordChatExporter.Domain.Markdown.Matching { internal class RegexMatcher : IMatcher { @@ -19,7 +19,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal { } - public ParsedMatch? Match(StringPart stringPart) + public ParsedMatch? TryMatch(StringPart stringPart) { var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length); if (!match.Success) diff --git a/DiscordChatExporter.Core.Markdown/Internal/StringMatcher.cs b/DiscordChatExporter.Domain/Markdown/Matching/StringMatcher.cs similarity index 89% rename from DiscordChatExporter.Core.Markdown/Internal/StringMatcher.cs rename to DiscordChatExporter.Domain/Markdown/Matching/StringMatcher.cs index 8639d66..e166dac 100644 --- a/DiscordChatExporter.Core.Markdown/Internal/StringMatcher.cs +++ b/DiscordChatExporter.Domain/Markdown/Matching/StringMatcher.cs @@ -1,6 +1,6 @@ using System; -namespace DiscordChatExporter.Core.Markdown.Internal +namespace DiscordChatExporter.Domain.Markdown.Matching { internal class StringMatcher : IMatcher { @@ -20,7 +20,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal { } - public ParsedMatch? Match(StringPart stringPart) + public ParsedMatch? TryMatch(StringPart stringPart) { var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison); diff --git a/DiscordChatExporter.Core.Markdown/Internal/StringPart.cs b/DiscordChatExporter.Domain/Markdown/Matching/StringPart.cs similarity index 94% rename from DiscordChatExporter.Core.Markdown/Internal/StringPart.cs rename to DiscordChatExporter.Domain/Markdown/Matching/StringPart.cs index 9cb35c4..ec076c6 100644 --- a/DiscordChatExporter.Core.Markdown/Internal/StringPart.cs +++ b/DiscordChatExporter.Domain/Markdown/Matching/StringPart.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace DiscordChatExporter.Core.Markdown.Internal +namespace DiscordChatExporter.Domain.Markdown.Matching { internal readonly struct StringPart { diff --git a/DiscordChatExporter.Domain/Utilities/AsyncExtensions.cs b/DiscordChatExporter.Domain/Utilities/AsyncExtensions.cs new file mode 100644 index 0000000..6806ea7 --- /dev/null +++ b/DiscordChatExporter.Domain/Utilities/AsyncExtensions.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace DiscordChatExporter.Domain.Utilities +{ + public static class AsyncExtensions + { + public static async Task ParallelForEachAsync(this IEnumerable source, Func handleAsync, int degreeOfParallelism) + { + using var semaphore = new SemaphoreSlim(degreeOfParallelism); + + await Task.WhenAll(source.Select(async item => + { + // ReSharper disable once AccessToDisposedClosure + await semaphore.WaitAsync(); + + try + { + await handleAsync(item); + } + finally + { + // ReSharper disable once AccessToDisposedClosure + semaphore.Release(); + } + })); + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Bootstrapper.cs b/DiscordChatExporter.Gui/Bootstrapper.cs index e40e971..2691ec6 100644 --- a/DiscordChatExporter.Gui/Bootstrapper.cs +++ b/DiscordChatExporter.Gui/Bootstrapper.cs @@ -1,4 +1,4 @@ -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.ViewModels; using DiscordChatExporter.Gui.ViewModels.Framework; using Stylet; @@ -17,9 +17,6 @@ namespace DiscordChatExporter.Gui { base.ConfigureIoC(builder); - // Autobind the .Services assembly - builder.Autobind(typeof(DataService).Assembly); - // Bind settings as singleton builder.Bind().ToSelf().InSingletonScope(); diff --git a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs index 5e1332a..17bda21 100644 --- a/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs +++ b/DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs @@ -1,7 +1,7 @@ using System; using System.Globalization; using System.Windows.Data; -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Domain.Exporting; namespace DiscordChatExporter.Gui.Converters { diff --git a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj index 29b6dba..9c6e9d7 100644 --- a/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj +++ b/DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj @@ -21,14 +21,12 @@ + - - - - + diff --git a/DiscordChatExporter.Core.Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs similarity index 85% rename from DiscordChatExporter.Core.Services/SettingsService.cs rename to DiscordChatExporter.Gui/Services/SettingsService.cs index 3ee3b6d..e34f137 100644 --- a/DiscordChatExporter.Core.Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -1,7 +1,8 @@ -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Domain.Discord; +using DiscordChatExporter.Domain.Exporting; using Tyrrrz.Settings; -namespace DiscordChatExporter.Core.Services +namespace DiscordChatExporter.Gui.Services { public class SettingsService : SettingsManager { diff --git a/DiscordChatExporter.Gui/Services/UpdateService.cs b/DiscordChatExporter.Gui/Services/UpdateService.cs index 12e72b3..f96c7c5 100644 --- a/DiscordChatExporter.Gui/Services/UpdateService.cs +++ b/DiscordChatExporter.Gui/Services/UpdateService.cs @@ -1,6 +1,5 @@ using System; using System.Threading.Tasks; -using DiscordChatExporter.Core.Services; using Onova; using Onova.Exceptions; using Onova.Services; diff --git a/DiscordChatExporter.Gui/ViewModels/Components/ChannelViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/ChannelViewModel.cs index 73e402b..cfeecce 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/ChannelViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/ChannelViewModel.cs @@ -1,4 +1,4 @@ -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Domain.Discord.Models; using Stylet; namespace DiscordChatExporter.Gui.ViewModels.Components diff --git a/DiscordChatExporter.Gui/ViewModels/Components/GuildViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/GuildViewModel.cs index 71344f6..9d65c3c 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/GuildViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/GuildViewModel.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Domain.Discord.Models; using Stylet; namespace DiscordChatExporter.Gui.ViewModels.Components diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs index 46148f4..507520b 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Services; -using DiscordChatExporter.Core.Services.Logic; +using DiscordChatExporter.Domain.Exporting; +using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Framework; @@ -62,7 +61,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs var channel = Channels.Single(); // Generate default file name - var defaultFileName = ExportLogic.GetDefaultExportFileName(SelectedFormat, Guild!, channel!, After, Before); + var defaultFileName = Exporter.GetDefaultExportFileName(SelectedFormat, Guild!, channel!, After, Before); // Generate filter var ext = SelectedFormat.GetFileExtension(); diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs index 3acc2fa..a608818 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs @@ -1,4 +1,4 @@ -using DiscordChatExporter.Core.Services; +using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.ViewModels.Framework; using Tyrrrz.Extensions; diff --git a/DiscordChatExporter.Gui/ViewModels/Framework/Extensions.cs b/DiscordChatExporter.Gui/ViewModels/Framework/Extensions.cs index 551ac89..84f32f6 100644 --- a/DiscordChatExporter.Gui/ViewModels/Framework/Extensions.cs +++ b/DiscordChatExporter.Gui/ViewModels/Framework/Extensions.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Dialogs; diff --git a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs index 117425d..7a0acac 100644 --- a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs @@ -1,13 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net; using System.Threading; using System.Threading.Tasks; -using DiscordChatExporter.Core.Models; -using DiscordChatExporter.Core.Models.Exceptions; -using DiscordChatExporter.Core.Services; -using DiscordChatExporter.Core.Services.Exceptions; +using DiscordChatExporter.Domain.Discord; +using DiscordChatExporter.Domain.Discord.Models; +using DiscordChatExporter.Domain.Exceptions; +using DiscordChatExporter.Domain.Exporting; +using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Framework; @@ -24,8 +24,6 @@ namespace DiscordChatExporter.Gui.ViewModels private readonly DialogManager _dialogManager; private readonly SettingsService _settingsService; private readonly UpdateService _updateService; - private readonly DataService _dataService; - private readonly ExportService _exportService; public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5)); @@ -45,16 +43,16 @@ namespace DiscordChatExporter.Gui.ViewModels public IReadOnlyList? SelectedChannels { get; set; } - public RootViewModel(IViewModelFactory viewModelFactory, DialogManager dialogManager, - SettingsService settingsService, UpdateService updateService, DataService dataService, - ExportService exportService) + public RootViewModel( + IViewModelFactory viewModelFactory, + DialogManager dialogManager, + SettingsService settingsService, + UpdateService updateService) { _viewModelFactory = viewModelFactory; _dialogManager = dialogManager; _settingsService = settingsService; _updateService = updateService; - _dataService = dataService; - _exportService = exportService; // Set title DisplayName = $"{App.Name} v{App.VersionString}"; @@ -67,6 +65,10 @@ namespace DiscordChatExporter.Gui.ViewModels (sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); } + private DiscordClient GetDiscordClient(AuthToken token) => new DiscordClient(token); + + private Exporter GetExporter(AuthToken token) => new Exporter(GetDiscordClient(token)); + private async Task HandleAutoUpdateAsync() { try @@ -160,7 +162,7 @@ namespace DiscordChatExporter.Gui.ViewModels // Get direct messages { var guild = Guild.DirectMessages; - var channels = await _dataService.GetDirectMessageChannelsAsync(token); + var channels = await GetDiscordClient(token).GetDirectMessageChannelsAsync(); // Create channel view models var channelViewModels = new List(); @@ -187,12 +189,12 @@ namespace DiscordChatExporter.Gui.ViewModels } // Get guilds - var guilds = await _dataService.GetUserGuildsAsync(token); + var guilds = await GetDiscordClient(token).GetUserGuildsAsync(); foreach (var guild in guilds) { - var channels = await _dataService.GetGuildChannelsAsync(token, guild.Id); + var channels = await GetDiscordClient(token).GetGuildChannelsAsync(guild.Id); var categoryChannels = channels.Where(c => c.Type == ChannelType.GuildCategory).ToArray(); - var exportableChannels = channels.Where(c => c.Type.IsExportable()).ToArray(); + var exportableChannels = channels.Where(c => c.IsTextChannel).ToArray(); // Create channel view models var channelViewModels = new List(); @@ -224,15 +226,7 @@ namespace DiscordChatExporter.Gui.ViewModels // Pre-select first guild SelectedGuild = AvailableGuilds.FirstOrDefault(); } - catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) - { - Notifications.Enqueue("Unauthorized – make sure the token is valid"); - } - catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden) - { - Notifications.Enqueue("Forbidden – account may be locked by 2FA"); - } - catch (DomainException ex) + catch (DiscordChatExporterException ex) when (!ex.IsCritical) { Notifications.Enqueue(ex.Message); } @@ -261,40 +255,27 @@ namespace DiscordChatExporter.Gui.ViewModels // Export channels var successfulExportCount = 0; - using var semaphore = new SemaphoreSlim(_settingsService.ParallelLimit.ClampMin(1)); - - await Task.WhenAll(dialog.Channels.Select(async (channel, i) => + await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple => { - var operation = operations[i]; - - await semaphore.WaitAsync(); + var (channel, operation) = tuple; try { - await _exportService.ExportChatLogAsync(token, dialog.Guild!, channel!, - dialog.OutputPath!, dialog.SelectedFormat, dialog.PartitionLimit, - dialog.After, dialog.Before, operation); + await GetExporter(token).ExportChatLogAsync(dialog.Guild!, channel!, + dialog.OutputPath!, dialog.SelectedFormat, _settingsService.DateFormat, + dialog.PartitionLimit, dialog.After, dialog.Before, operation); - successfulExportCount++; - } - catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden) - { - Notifications.Enqueue($"You don't have access to channel [{channel.Model!.Name}]"); - } - catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound) - { - Notifications.Enqueue($"Channel [{channel.Model!.Name}] doesn't exist"); + Interlocked.Increment(ref successfulExportCount); } - catch (DomainException ex) + catch (DiscordChatExporterException ex) when (!ex.IsCritical) { Notifications.Enqueue(ex.Message); } finally { operation.Dispose(); - semaphore.Release(); } - })); + }, _settingsService.ParallelLimit.ClampMin(1)); // Notify of overall completion if (successfulExportCount > 0) diff --git a/DiscordChatExporter.sln b/DiscordChatExporter.sln index 8169740..2bc3913 100644 --- a/DiscordChatExporter.sln +++ b/DiscordChatExporter.sln @@ -12,40 +12,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Dockerfile = Dockerfile EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Markdown", "DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj", "{14D02A08-E820-4012-B805-663B9A3D73E9}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Models", "DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj", "{67A9D184-4656-4CE1-9D75-BDDCBCAFB200}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Rendering", "DiscordChatExporter.Core.Rendering\DiscordChatExporter.Core.Rendering.csproj", "{D33F7443-4EEB-4E53-99BE-6045A62FC8C8}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Services", "DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Domain", "DiscordChatExporter.Domain\DiscordChatExporter.Domain.csproj", "{E19980B9-2B84-4257-A517-540FF1E3FCDD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {14D02A08-E820-4012-B805-663B9A3D73E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {14D02A08-E820-4012-B805-663B9A3D73E9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {14D02A08-E820-4012-B805-663B9A3D73E9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {14D02A08-E820-4012-B805-663B9A3D73E9}.Release|Any CPU.Build.0 = Release|Any CPU - {67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Debug|Any CPU.Build.0 = Debug|Any CPU - {67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Release|Any CPU.ActiveCfg = Release|Any CPU - {67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Release|Any CPU.Build.0 = Release|Any CPU - {D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Release|Any CPU.Build.0 = Release|Any CPU - {707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU - {707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU - {707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU {732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU {732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -54,6 +32,10 @@ Global {D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.Build.0 = Release|Any CPU + {E19980B9-2B84-4257-A517-540FF1E3FCDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E19980B9-2B84-4257-A517-540FF1E3FCDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E19980B9-2B84-4257-A517-540FF1E3FCDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E19980B9-2B84-4257-A517-540FF1E3FCDD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Dockerfile b/Dockerfile index c1c3772..6954709 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,11 +4,7 @@ WORKDIR /src COPY favicon.ico ./ COPY DiscordChatExporter.props ./ - -COPY DiscordChatExporter.Core.Markdown DiscordChatExporter.Core.Markdown -COPY DiscordChatExporter.Core.Models DiscordChatExporter.Core.Models -COPY DiscordChatExporter.Core.Rendering DiscordChatExporter.Core.Rendering -COPY DiscordChatExporter.Core.Services DiscordChatExporter.Core.Services +COPY DiscordChatExporter.Domain DiscordChatExporter.Domain COPY DiscordChatExporter.Cli DiscordChatExporter.Cli RUN dotnet publish DiscordChatExporter.Cli -o DiscordChatExporter.Cli/publish -c Release