mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-02-14 15:53:30 -07:00
Rework architecture
This commit is contained in:
parent
130c0b6fe2
commit
8685a3d7e3
|
|
@ -4,17 +4,13 @@ using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Utilities;
|
using CliFx.Utilities;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Domain.Exporting;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands.Base
|
||||||
{
|
{
|
||||||
public abstract class ExportCommandBase : TokenCommandBase
|
public abstract class ExportCommandBase : TokenCommandBase
|
||||||
{
|
{
|
||||||
protected SettingsService SettingsService { get; }
|
|
||||||
|
|
||||||
protected ExportService ExportService { get; }
|
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Output file format.")]
|
[CommandOption("format", 'f', Description = "Output file format.")]
|
||||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
|
|
@ -31,25 +27,17 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
public int? PartitionLimit { get; set; }
|
public int? PartitionLimit { get; set; }
|
||||||
|
|
||||||
[CommandOption("dateformat", Description = "Date format used in output.")]
|
[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)
|
protected Exporter GetExporter() => new Exporter(GetDiscordClient());
|
||||||
: base(dataService)
|
|
||||||
{
|
|
||||||
SettingsService = settingsService;
|
|
||||||
ExportService = exportService;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, Guild guild, Channel channel)
|
protected async ValueTask ExportAsync(IConsole console, Guild guild, Channel channel)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(DateFormat))
|
console.Output.Write($"Exporting channel '{channel.Name}'... ");
|
||||||
SettingsService.DateFormat = DateFormat;
|
|
||||||
|
|
||||||
console.Output.Write($"Exporting channel [{channel.Name}]... ");
|
|
||||||
var progress = console.CreateProgressTicker();
|
var progress = console.CreateProgressTicker();
|
||||||
|
|
||||||
await ExportService.ExportChatLogAsync(Token, guild, channel,
|
await GetExporter().ExportChatLogAsync(guild, channel,
|
||||||
OutputPath, ExportFormat, PartitionLimit,
|
OutputPath, ExportFormat, DateFormat, PartitionLimit,
|
||||||
After, Before, progress);
|
After, Before, progress);
|
||||||
|
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
@ -58,13 +46,13 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, Channel channel)
|
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);
|
await ExportAsync(console, guild, channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, string channelId)
|
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);
|
await ExportAsync(console, channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,38 +1,28 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Utilities;
|
using CliFx.Utilities;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Core.Models.Exceptions;
|
using DiscordChatExporter.Domain.Exceptions;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Domain.Utilities;
|
||||||
using DiscordChatExporter.Core.Services.Exceptions;
|
|
||||||
using Gress;
|
using Gress;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands.Base
|
||||||
{
|
{
|
||||||
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("parallel", Description = "Export this number of separate channels in parallel.")]
|
[CommandOption("parallel", Description = "Export this number of separate channels in parallel.")]
|
||||||
public int ParallelLimit { get; set; } = 1;
|
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<Channel> channels)
|
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
// This uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
|
// This uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
|
||||||
// Ugly code ahead. Will need to refactor.
|
// Ugly code ahead. Will need to refactor.
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(DateFormat))
|
|
||||||
SettingsService.DateFormat = DateFormat;
|
|
||||||
|
|
||||||
// Progress
|
// Progress
|
||||||
console.Output.Write($"Exporting {channels.Count} channels... ");
|
console.Output.Write($"Exporting {channels.Count} channels... ");
|
||||||
var ticker = console.CreateProgressTicker();
|
var ticker = console.CreateProgressTicker();
|
||||||
|
|
@ -44,41 +34,33 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
var operations = progressManager.CreateOperations(channels.Count);
|
var operations = progressManager.CreateOperations(channels.Count);
|
||||||
|
|
||||||
// Export channels
|
// Export channels
|
||||||
using var semaphore = new SemaphoreSlim(ParallelLimit.ClampMin(1));
|
|
||||||
|
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
await Task.WhenAll(channels.Select(async (channel, i) =>
|
var successfulExportCount = 0;
|
||||||
|
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||||
{
|
{
|
||||||
var operation = operations[i];
|
var (channel, operation) = tuple;
|
||||||
await semaphore.WaitAsync();
|
|
||||||
|
|
||||||
var guild = await DataService.GetGuildAsync(Token, channel.GuildId);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ExportService.ExportChatLogAsync(Token, guild, channel,
|
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
|
||||||
OutputPath, ExportFormat, PartitionLimit,
|
|
||||||
|
await GetExporter().ExportChatLogAsync(guild, channel,
|
||||||
|
OutputPath, ExportFormat, DateFormat, PartitionLimit,
|
||||||
After, Before, operation);
|
After, Before, operation);
|
||||||
|
|
||||||
|
Interlocked.Increment(ref successfulExportCount);
|
||||||
}
|
}
|
||||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
errors.Add(ex.Message);
|
errors.Add(ex.Message);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
semaphore.Release();
|
|
||||||
operation.Dispose();
|
operation.Dispose();
|
||||||
}
|
}
|
||||||
}));
|
}, ParallelLimit.ClampMin(1));
|
||||||
|
|
||||||
ticker.Report(1);
|
ticker.Report(1);
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
@ -86,7 +68,7 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
foreach (var error in errors)
|
foreach (var error in errors)
|
||||||
console.Error.WriteLine(error);
|
console.Error.WriteLine(error);
|
||||||
|
|
||||||
console.Output.WriteLine("Done.");
|
console.Output.WriteLine($"Successfully exported {successfulExportCount} channel(s).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord;
|
||||||
using DiscordChatExporter.Core.Services;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands.Base
|
||||||
{
|
{
|
||||||
public abstract class TokenCommandBase : ICommand
|
public abstract class TokenCommandBase : ICommand
|
||||||
{
|
{
|
||||||
protected DataService DataService { get; }
|
|
||||||
|
|
||||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN",
|
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN",
|
||||||
Description = "Authorization token.")]
|
Description = "Authorization token.")]
|
||||||
public string TokenValue { get; set; } = "";
|
public string TokenValue { get; set; } = "";
|
||||||
|
|
@ -18,12 +15,9 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
Description = "Whether this authorization token belongs to a bot.")]
|
Description = "Whether this authorization token belongs to a bot.")]
|
||||||
public bool IsBotToken { get; set; }
|
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)
|
protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken());
|
||||||
{
|
|
||||||
DataService = dataService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
|
|
@ -11,11 +11,6 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
|
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
|
||||||
public string ChannelId { get; set; } = "";
|
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);
|
public override async ValueTask ExecuteAsync(IConsole console) => await ExportAsync(console, ChannelId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,21 +2,16 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
[Command("exportdm", Description = "Export all direct message channels.")]
|
[Command("exportdm", Description = "Export all direct message channels.")]
|
||||||
public class ExportDirectMessagesCommand : ExportMultipleCommandBase
|
public class ExportDirectMessagesCommand : ExportMultipleCommandBase
|
||||||
{
|
{
|
||||||
public ExportDirectMessagesCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
|
|
||||||
: base(settingsService, dataService, exportService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
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();
|
var channels = directMessageChannels.OrderBy(c => c.Name).ToArray();
|
||||||
|
|
||||||
await ExportMultipleAsync(console, channels);
|
await ExportMultipleAsync(console, channels);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Services;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
|
|
@ -13,17 +12,12 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||||
public string GuildId { get; set; } = "";
|
public string GuildId { get; set; } = "";
|
||||||
|
|
||||||
public ExportGuildCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
|
|
||||||
: base(settingsService, dataService, exportService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var guildChannels = await DataService.GetGuildChannelsAsync(Token, GuildId);
|
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||||
|
|
||||||
var channels = guildChannels
|
var channels = guildChannels
|
||||||
.Where(c => c.Type.IsExportable())
|
.Where(c => c.IsTextChannel)
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Services;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
|
|
@ -13,17 +12,12 @@ namespace DiscordChatExporter.Cli.Commands
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||||
public string GuildId { get; set; } = "";
|
public string GuildId { get; set; } = "";
|
||||||
|
|
||||||
public GetChannelsCommand(DataService dataService)
|
|
||||||
: base(dataService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var guildChannels = await DataService.GetGuildChannelsAsync(Token, GuildId);
|
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||||
|
|
||||||
var channels = guildChannels
|
var channels = guildChannels
|
||||||
.Where(c => c.Type.IsExportable())
|
.Where(c => c.IsTextChannel)
|
||||||
.OrderBy(c => c.Name)
|
.OrderBy(c => c.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,16 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
[Command("dm", Description = "Get the list of direct message channels.")]
|
[Command("dm", Description = "Get the list of direct message channels.")]
|
||||||
public class GetDirectMessageChannelsCommand : TokenCommandBase
|
public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
public GetDirectMessageChannelsCommand(DataService dataService)
|
|
||||||
: base(dataService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
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();
|
var channels = directMessageChannels.OrderBy(c => c.Name).ToArray();
|
||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,17 @@
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
using DiscordChatExporter.Domain.Discord;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
[Command("guilds", Description = "Get the list of accessible guilds.")]
|
[Command("guilds", Description = "Get the list of accessible guilds.")]
|
||||||
public class GetGuildsCommand : TokenCommandBase
|
public class GetGuildsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
public GetGuildsCommand(DataService dataService)
|
|
||||||
: base(dataService)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
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))
|
foreach (var guild in guilds.OrderBy(g => g.Name))
|
||||||
console.Output.WriteLine($"{guild.Id} | {guild.Name}");
|
console.Output.WriteLine($"{guild.Id} | {guild.Name}");
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,11 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="1.0.0" />
|
<PackageReference Include="CliFx" Version="1.0.0" />
|
||||||
<PackageReference Include="Gress" Version="1.1.1" />
|
<PackageReference Include="Gress" Version="1.1.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.1" />
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Domain\DiscordChatExporter.Domain.csproj" />
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
@ -1,44 +1,14 @@
|
||||||
using System;
|
using System.Threading.Tasks;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using DiscordChatExporter.Cli.Commands;
|
|
||||||
using DiscordChatExporter.Core.Services;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli
|
namespace DiscordChatExporter.Cli
|
||||||
{
|
{
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
private static IServiceProvider ConfigureServices()
|
public static async Task<int> Main(string[] args) =>
|
||||||
{
|
await new CliApplicationBuilder()
|
||||||
var services = new ServiceCollection();
|
|
||||||
|
|
||||||
// Register services
|
|
||||||
services.AddSingleton<DataService>();
|
|
||||||
services.AddSingleton<ExportService>();
|
|
||||||
services.AddSingleton<SettingsService>();
|
|
||||||
|
|
||||||
// Register commands
|
|
||||||
services.AddTransient<ExportChannelCommand>();
|
|
||||||
services.AddTransient<ExportDirectMessagesCommand>();
|
|
||||||
services.AddTransient<ExportGuildCommand>();
|
|
||||||
services.AddTransient<GetChannelsCommand>();
|
|
||||||
services.AddTransient<GetDirectMessageChannelsCommand>();
|
|
||||||
services.AddTransient<GetGuildsCommand>();
|
|
||||||
services.AddTransient<GuideCommand>();
|
|
||||||
|
|
||||||
return services.BuildServiceProvider();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static async Task<int> Main(string[] args)
|
|
||||||
{
|
|
||||||
var serviceProvider = ConfigureServices();
|
|
||||||
|
|
||||||
return await new CliApplicationBuilder()
|
|
||||||
.AddCommandsFromThisAssembly()
|
.AddCommandsFromThisAssembly()
|
||||||
.UseTypeActivator(serviceProvider.GetService)
|
|
||||||
.Build()
|
.Build()
|
||||||
.RunAsync(args);
|
.RunAsync(args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
|
||||||
{
|
|
||||||
public class FormattedNode : Node
|
|
||||||
{
|
|
||||||
public TextFormatting Formatting { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Node> Children { get; }
|
|
||||||
|
|
||||||
public FormattedNode(TextFormatting formatting, IReadOnlyList<Node> children)
|
|
||||||
{
|
|
||||||
Formatting = formatting;
|
|
||||||
Children = children;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => $"<{Formatting}> ({Children.Count} direct children)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
|
||||||
{
|
|
||||||
public enum MentionType
|
|
||||||
{
|
|
||||||
Meta,
|
|
||||||
User,
|
|
||||||
Channel,
|
|
||||||
Role
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
|
||||||
{
|
|
||||||
public abstract class Node
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
|
||||||
{
|
|
||||||
public enum TextFormatting
|
|
||||||
{
|
|
||||||
Bold,
|
|
||||||
Italic,
|
|
||||||
Underline,
|
|
||||||
Strikethrough,
|
|
||||||
Spoiler,
|
|
||||||
Quote
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<Import Project="../DiscordChatExporter.props" />
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
|
||||||
{
|
|
||||||
internal interface IMatcher<T>
|
|
||||||
{
|
|
||||||
ParsedMatch<T>? Match(StringPart stringPart);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
namespace DiscordChatExporter.Core.Models
|
|
||||||
{
|
|
||||||
public enum AuthTokenType
|
|
||||||
{
|
|
||||||
User,
|
|
||||||
Bot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<Import Project="../DiscordChatExporter.props" />
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models.Exceptions
|
|
||||||
{
|
|
||||||
public class DomainException : Exception
|
|
||||||
{
|
|
||||||
public DomainException(string message)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
namespace DiscordChatExporter.Core.Models
|
|
||||||
{
|
|
||||||
public enum ExportFormat
|
|
||||||
{
|
|
||||||
PlainText,
|
|
||||||
HtmlDark,
|
|
||||||
HtmlLight,
|
|
||||||
Csv,
|
|
||||||
Json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Message> Messages { get; }
|
|
||||||
|
|
||||||
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
Author = author;
|
|
||||||
Timestamp = timestamp;
|
|
||||||
Messages = messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<Import Project="../DiscordChatExporter.props" />
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="Resources\HtmlCore.css" />
|
|
||||||
<EmbeddedResource Include="Resources\HtmlDark.css" />
|
|
||||||
<EmbeddedResource Include="Resources\HtmlLight.css" />
|
|
||||||
<EmbeddedResource Include="Resources\HtmlLayoutTemplate.html" />
|
|
||||||
<EmbeddedResource Include="Resources\HtmlMessageGroupTemplate.html" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Scriban" Version="2.1.1" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
|
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 $"<strong>{innerHtml}</strong>";
|
|
||||||
|
|
||||||
// Italic
|
|
||||||
if (formattedNode.Formatting == TextFormatting.Italic)
|
|
||||||
return $"<em>{innerHtml}</em>";
|
|
||||||
|
|
||||||
// Underline
|
|
||||||
if (formattedNode.Formatting == TextFormatting.Underline)
|
|
||||||
return $"<u>{innerHtml}</u>";
|
|
||||||
|
|
||||||
// Strikethrough
|
|
||||||
if (formattedNode.Formatting == TextFormatting.Strikethrough)
|
|
||||||
return $"<s>{innerHtml}</s>";
|
|
||||||
|
|
||||||
// Spoiler
|
|
||||||
if (formattedNode.Formatting == TextFormatting.Spoiler)
|
|
||||||
return $"<span class=\"spoiler spoiler--hidden\" onclick=\"showSpoiler(event, this)\"><span class=\"spoiler-text\">{innerHtml}</span></span>";
|
|
||||||
|
|
||||||
// Quote
|
|
||||||
if (formattedNode.Formatting == TextFormatting.Quote)
|
|
||||||
return $"<div class=\"quote\">{innerHtml}</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inline code block node
|
|
||||||
if (node is InlineCodeBlockNode inlineCodeBlockNode)
|
|
||||||
{
|
|
||||||
return $"<span class=\"pre pre--inline\">{HtmlEncode(inlineCodeBlockNode.Code)}</span>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 $"<div class=\"pre pre--multiline {highlightCssClass}\">{HtmlEncode(multilineCodeBlockNode.Code)}</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mention node
|
|
||||||
if (node is MentionNode mentionNode)
|
|
||||||
{
|
|
||||||
// Meta mention node
|
|
||||||
if (mentionNode.Type == MentionType.Meta)
|
|
||||||
{
|
|
||||||
return $"<span class=\"mention\">@{HtmlEncode(mentionNode.Id)}</span>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 $"<span class=\"mention\" title=\"{HtmlEncode(user.FullName)}\">@{HtmlEncode(nick)}</span>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel mention node
|
|
||||||
if (mentionNode.Type == MentionType.Channel)
|
|
||||||
{
|
|
||||||
var channel = context.MentionableChannels.FirstOrDefault(c => c.Id == mentionNode.Id) ??
|
|
||||||
Channel.CreateDeletedChannel(mentionNode.Id);
|
|
||||||
|
|
||||||
return $"<span class=\"mention\">#{HtmlEncode(channel.Name)}</span>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 $"<span class=\"mention\" {style}>@{HtmlEncode(role.Name)}</span>";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
$"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
? $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\">{HtmlEncode(linkNode.Title)}</a>"
|
|
||||||
: $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">{HtmlEncode(linkNode.Title)}</a>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throw on unexpected nodes
|
|
||||||
throw new InvalidOperationException($"Unexpected node [{node.GetType()}].");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatMarkdownNodes(RenderContext context, IReadOnlyList<Node> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<Attachment> 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<Embed> 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<Reaction> 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
<Import Project="../DiscordChatExporter.props" />
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Polly" Version="7.2.0" />
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core.Rendering\DiscordChatExporter.Core.Rendering.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<double>? 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<User>(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<User>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services.Internal.Extensions
|
|
||||||
{
|
|
||||||
internal static class GenericExtensions
|
|
||||||
{
|
|
||||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Domain.Discord
|
||||||
{
|
{
|
||||||
public static class Extensions
|
public static class AccessibilityExtensions
|
||||||
{
|
{
|
||||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable)
|
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable)
|
||||||
{
|
{
|
||||||
29
DiscordChatExporter.Domain/Discord/AuthToken.cs
Normal file
29
DiscordChatExporter.Domain/Discord/AuthToken.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,13 @@
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Core.Services.Internal.Extensions;
|
using DiscordChatExporter.Domain.Internal;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Domain.Discord
|
||||||
{
|
{
|
||||||
public partial class DataService
|
public partial class DiscordClient
|
||||||
{
|
{
|
||||||
private string ParseId(JsonElement json) =>
|
private string ParseId(JsonElement json) =>
|
||||||
json.GetProperty("id").GetString();
|
json.GetProperty("id").GetString();
|
||||||
|
|
@ -42,7 +42,7 @@ namespace DiscordChatExporter.Core.Services
|
||||||
var roles = json.GetPropertyOrNull("roles")?.EnumerateArray().Select(ParseRole).ToArray() ??
|
var roles = json.GetPropertyOrNull("roles")?.EnumerateArray().Select(ParseRole).ToArray() ??
|
||||||
Array.Empty<Role>();
|
Array.Empty<Role>();
|
||||||
|
|
||||||
return new Guild(id, name, roles, iconHash);
|
return new Guild(id, name, iconHash, roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Channel ParseChannel(JsonElement json)
|
private Channel ParseChannel(JsonElement json)
|
||||||
|
|
@ -60,14 +60,14 @@ namespace DiscordChatExporter.Core.Services
|
||||||
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(ParseUser).Select(u => u.Name).JoinToString(", ") ??
|
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(ParseUser).Select(u => u.Name).JoinToString(", ") ??
|
||||||
id;
|
id;
|
||||||
|
|
||||||
return new Channel(id, parentId, guildId, name, topic, type);
|
return new Channel(id, guildId, parentId, type, name, topic);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Role ParseRole(JsonElement json)
|
private Role ParseRole(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = ParseId(json);
|
var id = ParseId(json);
|
||||||
var name = json.GetProperty("name").GetString();
|
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();
|
var position = json.GetProperty("position").GetInt32();
|
||||||
|
|
||||||
return new Role(id, name, color, position);
|
return new Role(id, name, color, position);
|
||||||
|
|
@ -82,7 +82,7 @@ namespace DiscordChatExporter.Core.Services
|
||||||
var fileName = json.GetProperty("filename").GetString();
|
var fileName = json.GetProperty("filename").GetString();
|
||||||
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
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)
|
private EmbedAuthor ParseEmbedAuthor(JsonElement json)
|
||||||
|
|
@ -153,7 +153,7 @@ namespace DiscordChatExporter.Core.Services
|
||||||
var count = json.GetProperty("count").GetInt32();
|
var count = json.GetProperty("count").GetInt32();
|
||||||
var emoji = json.GetProperty("emoji").Pipe(ParseEmoji);
|
var emoji = json.GetProperty("emoji").Pipe(ParseEmoji);
|
||||||
|
|
||||||
return new Reaction(count, emoji);
|
return new Reaction(emoji, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Message ParseMessage(JsonElement json)
|
private Message ParseMessage(JsonElement json)
|
||||||
|
|
@ -3,29 +3,29 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Core.Services.Exceptions;
|
using DiscordChatExporter.Domain.Exceptions;
|
||||||
using DiscordChatExporter.Core.Services.Internal;
|
using DiscordChatExporter.Domain.Internal;
|
||||||
using DiscordChatExporter.Core.Services.Internal.Extensions;
|
|
||||||
using Polly;
|
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 AuthToken _token;
|
||||||
private readonly IAsyncPolicy<HttpResponseMessage> _httpPolicy;
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly IAsyncPolicy<HttpResponseMessage> _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).
|
// 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.
|
// For that reason the policy will start respecting their retry-after header only after Nth failed response.
|
||||||
_httpPolicy = Policy
|
_httpRequestPolicy = Policy
|
||||||
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||||
.WaitAndRetryAsync(6,
|
.WaitAndRetryAsync(6,
|
||||||
|
|
@ -42,64 +42,70 @@ namespace DiscordChatExporter.Core.Services
|
||||||
(response, timespan, retryCount, context) => Task.CompletedTask);
|
(response, timespan, retryCount, context) => Task.CompletedTask);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement> GetApiResponseAsync(AuthToken token, string route)
|
public DiscordClient(AuthToken token)
|
||||||
|
: this(token, LazyHttpClient.Value)
|
||||||
{
|
{
|
||||||
return (await GetApiResponseAsync(token, route, true))!.Value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement?> GetApiResponseAsync(AuthToken token, string route, bool errorOnFail)
|
private async Task<JsonElement> 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
|
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
? new AuthenticationHeaderValue("Bot", token.Value)
|
|
||||||
: new AuthenticationHeaderValue(token.Value);
|
|
||||||
|
|
||||||
return await _httpClient.SendAsync(request);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// We throw our own exception here because default one doesn't have status code
|
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
if (!response.IsSuccessStatusCode)
|
throw DiscordChatExporterException.Unauthorized();
|
||||||
{
|
|
||||||
if (errorOnFail)
|
|
||||||
throw new HttpErrorStatusCodeException(response.StatusCode, response.ReasonPhrase);
|
|
||||||
|
|
||||||
|
if ((int) response.StatusCode >= 400)
|
||||||
|
throw DiscordChatExporterException.FailedHttpRequest(response);
|
||||||
|
|
||||||
|
return await response.Content.ReadAsJsonAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: do we need this?
|
||||||
|
private async Task<JsonElement?> TryGetApiResponseAsync(string url)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await GetApiResponseAsync(url);
|
||||||
|
}
|
||||||
|
catch (DiscordChatExporterException)
|
||||||
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsonRaw = await response.Content.ReadAsStringAsync();
|
|
||||||
return Json.Parse(jsonRaw);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Guild> GetGuildAsync(AuthToken token, string guildId)
|
public async Task<Guild> GetGuildAsync(string guildId)
|
||||||
{
|
{
|
||||||
// Special case for direct messages pseudo-guild
|
// Special case for direct messages pseudo-guild
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
return Guild.DirectMessages;
|
return Guild.DirectMessages;
|
||||||
|
|
||||||
var response = await GetApiResponseAsync(token, $"guilds/{guildId}");
|
var response = await GetApiResponseAsync($"guilds/{guildId}");
|
||||||
var guild = ParseGuild(response);
|
var guild = ParseGuild(response);
|
||||||
|
|
||||||
return guild;
|
return guild;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Member?> GetGuildMemberAsync(AuthToken token, string guildId, string userId)
|
public async Task<Member?> 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);
|
return response?.Pipe(ParseMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Channel> GetChannelAsync(AuthToken token, string channelId)
|
public async Task<Channel> GetChannelAsync(string channelId)
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, $"channels/{channelId}");
|
var response = await GetApiResponseAsync($"channels/{channelId}");
|
||||||
var channel = ParseChannel(response);
|
var channel = ParseChannel(response);
|
||||||
|
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(AuthToken token)
|
public async IAsyncEnumerable<Guild> GetUserGuildsAsync()
|
||||||
{
|
{
|
||||||
var afterId = "";
|
var afterId = "";
|
||||||
|
|
||||||
|
|
@ -109,7 +115,7 @@ namespace DiscordChatExporter.Core.Services
|
||||||
if (!string.IsNullOrWhiteSpace(afterId))
|
if (!string.IsNullOrWhiteSpace(afterId))
|
||||||
route += $"&after={afterId}";
|
route += $"&after={afterId}";
|
||||||
|
|
||||||
var response = await GetApiResponseAsync(token, route);
|
var response = await GetApiResponseAsync(route);
|
||||||
|
|
||||||
var isEmpty = true;
|
var isEmpty = true;
|
||||||
|
|
||||||
|
|
@ -118,7 +124,7 @@ namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
var guildId = ParseId(guildJson);
|
var guildId = ParseId(guildJson);
|
||||||
|
|
||||||
yield return await GetGuildAsync(token, guildId);
|
yield return await GetGuildAsync(guildId);
|
||||||
afterId = guildId;
|
afterId = guildId;
|
||||||
|
|
||||||
isEmpty = false;
|
isEmpty = false;
|
||||||
|
|
@ -129,42 +135,42 @@ namespace DiscordChatExporter.Core.Services
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token)
|
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync()
|
||||||
{
|
{
|
||||||
var response = await GetApiResponseAsync(token, "users/@me/channels");
|
var response = await GetApiResponseAsync("users/@me/channels");
|
||||||
var channels = response.EnumerateArray().Select(ParseChannel).ToArray();
|
var channels = response.EnumerateArray().Select(ParseChannel).ToArray();
|
||||||
|
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId)
|
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string guildId)
|
||||||
{
|
{
|
||||||
// Special case for direct messages pseudo-guild
|
// Special case for direct messages pseudo-guild
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
return Array.Empty<Channel>();
|
return Array.Empty<Channel>();
|
||||||
|
|
||||||
var response = await GetApiResponseAsync(token, $"guilds/{guildId}/channels");
|
var response = await GetApiResponseAsync($"guilds/{guildId}/channels");
|
||||||
var channels = response.EnumerateArray().Select(ParseChannel).ToArray();
|
var channels = response.EnumerateArray().Select(ParseChannel).ToArray();
|
||||||
|
|
||||||
return channels;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Message> GetLastMessageAsync(AuthToken token, string channelId, DateTimeOffset? before = null)
|
private async Task<Message> GetLastMessageAsync(string channelId, DateTimeOffset? before = null)
|
||||||
{
|
{
|
||||||
var route = $"channels/{channelId}/messages?limit=1";
|
var route = $"channels/{channelId}/messages?limit=1";
|
||||||
if (before != null)
|
if (before != null)
|
||||||
route += $"&before={before.Value.ToSnowflake()}";
|
route += $"&before={before.Value.ToSnowflake()}";
|
||||||
|
|
||||||
var response = await GetApiResponseAsync(token, route);
|
var response = await GetApiResponseAsync(route);
|
||||||
|
|
||||||
return response.EnumerateArray().Select(ParseMessage).FirstOrDefault();
|
return response.EnumerateArray().Select(ParseMessage).FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Message> GetMessagesAsync(AuthToken token, string channelId,
|
public async IAsyncEnumerable<Message> GetMessagesAsync(string channelId,
|
||||||
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double>? progress = null)
|
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double>? progress = null)
|
||||||
{
|
{
|
||||||
// Get the last message
|
// 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 the last message doesn't exist or it's outside of range - return
|
||||||
if (lastMessage == null || lastMessage.Timestamp < after)
|
if (lastMessage == null || lastMessage.Timestamp < after)
|
||||||
|
|
@ -180,7 +186,7 @@ namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
// Get message batch
|
// Get message batch
|
||||||
var route = $"channels/{channelId}/messages?limit=100&after={afterId}";
|
var route = $"channels/{channelId}/messages?limit=100&after={afterId}";
|
||||||
var response = await GetApiResponseAsync(token, route);
|
var response = await GetApiResponseAsync(route);
|
||||||
|
|
||||||
// Parse
|
// Parse
|
||||||
var messages = response
|
var messages = response
|
||||||
|
|
@ -221,7 +227,23 @@ namespace DiscordChatExporter.Core.Services
|
||||||
yield return lastMessage;
|
yield return lastMessage;
|
||||||
progress?.Report(1);
|
progress?.Report(1);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => _httpClient.Dispose();
|
public partial class DiscordClient
|
||||||
|
{
|
||||||
|
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler();
|
||||||
|
|
||||||
|
if (handler.SupportsAutomaticDecompression)
|
||||||
|
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
handler.UseCookies = false;
|
||||||
|
|
||||||
|
return new HttpClient(handler, true)
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://discordapp.com/api/v6")
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||||
|
|
||||||
|
|
@ -12,30 +12,26 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public string Url { get; }
|
public string Url { get; }
|
||||||
|
|
||||||
|
public string FileName { get; }
|
||||||
|
|
||||||
public int? Width { get; }
|
public int? Width { get; }
|
||||||
|
|
||||||
public int? Height { get; }
|
public int? Height { get; }
|
||||||
|
|
||||||
public string FileName { get; }
|
public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
public bool IsImage { get; }
|
public bool IsSpoiler => IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||||
|
|
||||||
public bool IsSpoiler { get; }
|
|
||||||
|
|
||||||
public FileSize FileSize { get; }
|
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;
|
Id = id;
|
||||||
Url = url;
|
Url = url;
|
||||||
|
FileName = fileName;
|
||||||
Width = width;
|
Width = width;
|
||||||
Height = height;
|
Height = height;
|
||||||
FileName = fileName;
|
|
||||||
FileSize = fileSize;
|
FileSize = fileSize;
|
||||||
|
|
||||||
IsImage = GetIsImage(fileName);
|
|
||||||
|
|
||||||
IsSpoiler = IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => FileName;
|
public override string ToString() => FileName;
|
||||||
|
|
@ -44,11 +40,5 @@ namespace DiscordChatExporter.Core.Models
|
||||||
public partial class Attachment
|
public partial class Attachment
|
||||||
{
|
{
|
||||||
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
|
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
|
||||||
|
|
||||||
public static bool GetIsImage(string fileName)
|
|
||||||
{
|
|
||||||
var fileExtension = Path.GetExtension(fileName);
|
|
||||||
return ImageFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
58
DiscordChatExporter.Domain/Discord/Models/Channel.cs
Normal file
58
DiscordChatExporter.Domain/Discord/Models/Channel.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#embed-object
|
// https://discordapp.com/developers/docs/resources/channel#embed-object
|
||||||
|
|
||||||
|
|
@ -28,8 +28,17 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public EmbedFooter? Footer { get; }
|
public EmbedFooter? Footer { get; }
|
||||||
|
|
||||||
public Embed(string? title, string? url, DateTimeOffset? timestamp, Color? color, EmbedAuthor? author, string? description,
|
public Embed(
|
||||||
IReadOnlyList<EmbedField> fields, EmbedImage? thumbnail, EmbedImage? image, EmbedFooter? footer)
|
string? title,
|
||||||
|
string? url,
|
||||||
|
DateTimeOffset? timestamp,
|
||||||
|
Color? color,
|
||||||
|
EmbedAuthor? author,
|
||||||
|
string? description,
|
||||||
|
IReadOnlyList<EmbedField> fields,
|
||||||
|
EmbedImage? thumbnail,
|
||||||
|
EmbedImage? image,
|
||||||
|
EmbedFooter? footer)
|
||||||
{
|
{
|
||||||
Title = title;
|
Title = title;
|
||||||
Url = url;
|
Url = url;
|
||||||
|
|
@ -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
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||||
|
|
||||||
|
|
@ -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
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||||
|
|
||||||
|
|
@ -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
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||||
|
|
||||||
|
|
@ -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
|
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@ using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
|
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
|
||||||
|
|
||||||
|
|
@ -25,6 +25,8 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
ImageUrl = GetImageUrl(id, name, isAnimated);
|
ImageUrl = GetImageUrl(id, name, isAnimated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Name;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Emoji
|
public partial class Emoji
|
||||||
|
|
@ -58,17 +60,12 @@ namespace DiscordChatExporter.Core.Models
|
||||||
return $"https://cdn.discordapp.com/emojis/{id}.png";
|
return $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard unicode emoji
|
// Get runes
|
||||||
var emojiRunes = GetRunes(name).ToArray();
|
var emojiRunes = GetRunes(name).ToArray();
|
||||||
if (emojiRunes.Any())
|
|
||||||
{
|
|
||||||
// Get corresponding Twemoji image
|
// Get corresponding Twemoji image
|
||||||
var twemojiName = GetTwemojiName(emojiRunes);
|
var twemojiName = GetTwemojiName(emojiRunes);
|
||||||
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
|
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback in case of failure
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// Loosely based on https://github.com/omar/ByteSize (MIT license)
|
// Loosely based on https://github.com/omar/ByteSize (MIT license)
|
||||||
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
|
||||||
using System.Linq;
|
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
|
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
||||||
|
|
||||||
|
|
@ -15,21 +15,21 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public string? IconHash { get; }
|
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<Role> Roles { get; }
|
public IReadOnlyList<Role> Roles { get; }
|
||||||
|
|
||||||
public Dictionary<string, Member?> Members { get; }
|
public Dictionary<string, Member?> Members { get; }
|
||||||
|
|
||||||
public string IconUrl { get; }
|
public Guild(string id, string name, string? iconHash, IReadOnlyList<Role> roles)
|
||||||
|
|
||||||
public Guild(string id, string name, IReadOnlyList<Role> roles, string? iconHash)
|
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
IconHash = iconHash;
|
IconHash = iconHash;
|
||||||
Roles = roles;
|
Roles = roles;
|
||||||
Members = new Dictionary<string, Member?>();
|
Members = new Dictionary<string, Member?>();
|
||||||
|
|
||||||
IconUrl = GetIconUrl(id, iconHash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
|
|
@ -38,22 +38,17 @@ namespace DiscordChatExporter.Core.Models
|
||||||
public partial class Guild
|
public partial class Guild
|
||||||
{
|
{
|
||||||
public static string GetUserColor(Guild guild, User user) =>
|
public static string GetUserColor(Guild guild, User user) =>
|
||||||
guild.Members.GetValueOrDefault(user.Id, null)
|
guild.Members.GetValueOrDefault(user.Id, null)?
|
||||||
?.Roles
|
.RoleIds
|
||||||
.Select(r => guild.Roles.FirstOrDefault(role => r == role.Id))
|
.Select(r => guild.Roles.FirstOrDefault(role => r == role.Id))
|
||||||
.Where(r => r != null)
|
.Where(r => r != null)
|
||||||
.Where(r => r.Color != Color.Black)
|
.Where(r => r.Color != null)
|
||||||
.Where(r => r.Color.R + r.Color.G + r.Color.B > 0)
|
.Aggregate<Role, Role?>(null, (a, b) => (a?.Position ?? 0) > b.Position ? a : b)?
|
||||||
.Aggregate<Role, Role?>(null, (a, b) => (a?.Position ?? 0) > b.Position ? a : b)
|
.Color?
|
||||||
?.ColorAsHex ?? "";
|
.ToHexString() ?? "";
|
||||||
|
|
||||||
public static string GetUserNick(Guild guild, User user) => guild.Members.GetValueOrDefault(user.Id)?.Nick ?? user.Name;
|
public static string GetUserNick(Guild guild, User user) => guild.Members.GetValueOrDefault(user.Id)?.Nick ?? user.Name;
|
||||||
|
|
||||||
public static string GetIconUrl(string id, string? iconHash) =>
|
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null, Array.Empty<Role>());
|
||||||
!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<Role>(), null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
public interface IHasId
|
public interface IHasId
|
||||||
{
|
{
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
|
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
|
||||||
{
|
{
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/guild#guild-member-object
|
// https://discordapp.com/developers/docs/resources/guild#guild-member-object
|
||||||
|
|
||||||
|
|
@ -10,13 +10,13 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public string? Nick { get; }
|
public string? Nick { get; }
|
||||||
|
|
||||||
public IReadOnlyList<string> Roles { get; }
|
public IReadOnlyList<string> RoleIds { get; }
|
||||||
|
|
||||||
public Member(string userId, string? nick, IReadOnlyList<string> roles)
|
public Member(string userId, string? nick, IReadOnlyList<string> roleIds)
|
||||||
{
|
{
|
||||||
UserId = userId;
|
UserId = userId;
|
||||||
Nick = nick;
|
Nick = nick;
|
||||||
Roles = roles;
|
RoleIds = roleIds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,23 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
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
|
// https://discordapp.com/developers/docs/resources/channel#message-object
|
||||||
|
|
||||||
public class Message : IHasId
|
public class Message : IHasId
|
||||||
|
|
@ -21,7 +36,7 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public bool IsPinned { get; }
|
public bool IsPinned { get; }
|
||||||
|
|
||||||
public string? Content { get; }
|
public string Content { get; }
|
||||||
|
|
||||||
public IReadOnlyList<Attachment> Attachments { get; }
|
public IReadOnlyList<Attachment> Attachments { get; }
|
||||||
|
|
||||||
|
|
@ -31,10 +46,18 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public IReadOnlyList<User> MentionedUsers { get; }
|
public IReadOnlyList<User> MentionedUsers { get; }
|
||||||
|
|
||||||
public Message(string id, string channelId, MessageType type, User author,
|
public Message(
|
||||||
DateTimeOffset timestamp, DateTimeOffset? editedTimestamp, bool isPinned,
|
string id,
|
||||||
|
string channelId,
|
||||||
|
MessageType type,
|
||||||
|
User author,
|
||||||
|
DateTimeOffset timestamp,
|
||||||
|
DateTimeOffset? editedTimestamp,
|
||||||
|
bool isPinned,
|
||||||
string content,
|
string content,
|
||||||
IReadOnlyList<Attachment> attachments,IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions,
|
IReadOnlyList<Attachment> attachments,
|
||||||
|
IReadOnlyList<Embed> embeds,
|
||||||
|
IReadOnlyList<Reaction> reactions,
|
||||||
IReadOnlyList<User> mentionedUsers)
|
IReadOnlyList<User> mentionedUsers)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
|
|
@ -51,6 +74,9 @@ namespace DiscordChatExporter.Core.Models
|
||||||
MentionedUsers = mentionedUsers;
|
MentionedUsers = mentionedUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => Content ?? "<message without content>";
|
public override string ToString() =>
|
||||||
|
Content ?? (Embeds.Any()
|
||||||
|
? "<embed>"
|
||||||
|
: "<no content>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#reaction-object
|
// https://discordapp.com/developers/docs/resources/channel#reaction-object
|
||||||
|
|
||||||
public class Reaction
|
public class Reaction
|
||||||
{
|
{
|
||||||
public int Count { get; }
|
|
||||||
|
|
||||||
public Emoji Emoji { 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;
|
Emoji = emoji;
|
||||||
}
|
Count = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"{Emoji} ({Count})";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||||
|
|
||||||
|
|
@ -10,15 +10,11 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
|
|
||||||
public Color Color { get; }
|
public Color? Color { get; }
|
||||||
|
|
||||||
public string ColorAsHex => $"#{Color.ToArgb() & 0xffffff:X6}";
|
|
||||||
|
|
||||||
public string ColorAsRgb => $"{Color.R}, {Color.G}, {Color.B}";
|
|
||||||
|
|
||||||
public int Position { 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;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
|
|
@ -31,6 +27,6 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public partial class Role
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/user#user-object
|
// https://discordapp.com/developers/docs/resources/user#user-object
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
|
|
||||||
public string FullName { get; }
|
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||||
|
|
||||||
public string? AvatarHash { get; }
|
public string? AvatarHash { get; }
|
||||||
|
|
||||||
|
|
@ -28,7 +28,6 @@ namespace DiscordChatExporter.Core.Models
|
||||||
AvatarHash = avatarHash;
|
AvatarHash = avatarHash;
|
||||||
IsBot = isBot;
|
IsBot = isBot;
|
||||||
|
|
||||||
FullName = GetFullName(name, discriminator);
|
|
||||||
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
|
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,9 +36,7 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public partial class User
|
public partial class User
|
||||||
{
|
{
|
||||||
public static string GetFullName(string name, int discriminator) => $"{name}#{discriminator:0000}";
|
private static string GetAvatarUrl(string id, int discriminator, string? avatarHash)
|
||||||
|
|
||||||
public static string GetAvatarUrl(string id, int discriminator, string? avatarHash)
|
|
||||||
{
|
{
|
||||||
// Custom avatar
|
// Custom avatar
|
||||||
if (!string.IsNullOrWhiteSpace(avatarHash))
|
if (!string.IsNullOrWhiteSpace(avatarHash))
|
||||||
18
DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj
Normal file
18
DiscordChatExporter.Domain/DiscordChatExporter.Domain.csproj
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<Import Project="../DiscordChatExporter.props" />
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Polly" Version="7.2.0" />
|
||||||
|
<PackageReference Include="Scriban" Version="2.1.1" />
|
||||||
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Exporting\Resources\HtmlCore.css" />
|
||||||
|
<EmbeddedResource Include="Exporting\Resources\HtmlDark.css" />
|
||||||
|
<EmbeddedResource Include="Exporting\Resources\HtmlLayoutTemplate.html" />
|
||||||
|
<EmbeddedResource Include="Exporting\Resources\HtmlLight.css" />
|
||||||
|
<EmbeddedResource Include="Exporting\Resources\HtmlMessageGroupTemplate.html" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
DiscordChatExporter.Domain/Exporting/ExportFormat.cs
Normal file
36
DiscordChatExporter.Domain/Exporting/ExportFormat.cs
Normal file
|
|
@ -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))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
134
DiscordChatExporter.Domain/Exporting/Exporter.cs
Normal file
134
DiscordChatExporter.Domain/Exporting/Exporter.cs
Normal file
|
|
@ -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<double>? 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<User>(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<User>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
DiscordChatExporter.Domain/Exporting/MessageGroup.cs
Normal file
32
DiscordChatExporter.Domain/Exporting/MessageGroup.cs
Normal file
|
|
@ -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<Message> Messages { get; }
|
||||||
|
|
||||||
|
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Core.Rendering.Formatters;
|
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 RenderOptions _options;
|
||||||
private readonly RenderContext _context;
|
private readonly RenderContext _context;
|
||||||
|
|
@ -21,7 +21,7 @@ namespace DiscordChatExporter.Core.Rendering
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task InitializeWriterAsync()
|
private async Task<MessageWriterBase> InitializeWriterAsync()
|
||||||
{
|
{
|
||||||
// Get partition file path
|
// Get partition file path
|
||||||
var filePath = GetPartitionFilePath(_options.BaseFilePath, _partitionIndex);
|
var filePath = GetPartitionFilePath(_options.BaseFilePath, _partitionIndex);
|
||||||
|
|
@ -32,10 +32,12 @@ namespace DiscordChatExporter.Core.Rendering
|
||||||
Directory.CreateDirectory(dirPath);
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
// Create writer
|
// Create writer
|
||||||
_writer = CreateMessageWriter(filePath, _options.Format, _context);
|
var writer = CreateMessageWriter(filePath, _options.Format, _context);
|
||||||
|
|
||||||
// Write preamble
|
// Write preamble
|
||||||
await _writer.WritePreambleAsync();
|
await writer.WritePreambleAsync();
|
||||||
|
|
||||||
|
return _writer = writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ResetWriterAsync()
|
private async Task ResetWriterAsync()
|
||||||
|
|
@ -54,8 +56,7 @@ namespace DiscordChatExporter.Core.Rendering
|
||||||
public async Task RenderMessageAsync(Message message)
|
public async Task RenderMessageAsync(Message message)
|
||||||
{
|
{
|
||||||
// Ensure underlying writer is initialized
|
// Ensure underlying writer is initialized
|
||||||
if (_writer == null)
|
_writer ??= await InitializeWriterAsync();
|
||||||
await InitializeWriterAsync();
|
|
||||||
|
|
||||||
// Render the actual message
|
// Render the actual message
|
||||||
await _writer!.WriteMessageAsync(message);
|
await _writer!.WriteMessageAsync(message);
|
||||||
|
|
@ -76,7 +77,7 @@ namespace DiscordChatExporter.Core.Rendering
|
||||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class MessageRenderer
|
internal partial class MessageRenderer
|
||||||
{
|
{
|
||||||
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
|
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)
|
// Create a stream (it will get disposed by the writer)
|
||||||
var stream = File.Create(filePath);
|
var stream = File.Create(filePath);
|
||||||
|
|
||||||
// Create formatter
|
return format switch
|
||||||
if (format == ExportFormat.PlainText)
|
{
|
||||||
return new PlainTextMessageWriter(stream, context);
|
ExportFormat.PlainText => new PlainTextMessageWriter(stream, context),
|
||||||
|
ExportFormat.Csv => new CsvMessageWriter(stream, context),
|
||||||
if (format == ExportFormat.Csv)
|
ExportFormat.HtmlDark => new HtmlMessageWriter(stream, context, "Dark"),
|
||||||
return new CsvMessageWriter(stream, context);
|
ExportFormat.HtmlLight => new HtmlMessageWriter(stream, context, "Light"),
|
||||||
|
ExportFormat.Json => new JsonMessageWriter(stream, context),
|
||||||
if (format == ExportFormat.HtmlDark)
|
_ => throw new ArgumentOutOfRangeException(nameof(format), $"Unknown export format '{format}'.")
|
||||||
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}].");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Rendering
|
namespace DiscordChatExporter.Domain.Exporting
|
||||||
{
|
{
|
||||||
public class RenderContext
|
public class RenderContext
|
||||||
{
|
{
|
||||||
|
|
@ -22,8 +22,16 @@ namespace DiscordChatExporter.Core.Rendering
|
||||||
|
|
||||||
public IReadOnlyCollection<Role> MentionableRoles { get; }
|
public IReadOnlyCollection<Role> MentionableRoles { get; }
|
||||||
|
|
||||||
public RenderContext(Guild guild, Channel channel, DateTimeOffset? after, DateTimeOffset? before, string dateFormat,
|
public RenderContext(
|
||||||
IReadOnlyCollection<User> mentionableUsers, IReadOnlyCollection<Channel> mentionableChannels, IReadOnlyCollection<Role> mentionableRoles)
|
Guild guild,
|
||||||
|
Channel channel,
|
||||||
|
DateTimeOffset? after,
|
||||||
|
DateTimeOffset? before,
|
||||||
|
string dateFormat,
|
||||||
|
IReadOnlyCollection<User> mentionableUsers,
|
||||||
|
IReadOnlyCollection<Channel> mentionableChannels,
|
||||||
|
IReadOnlyCollection<Role> mentionableRoles)
|
||||||
|
|
||||||
{
|
{
|
||||||
Guild = guild;
|
Guild = guild;
|
||||||
Channel = channel;
|
Channel = channel;
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
using DiscordChatExporter.Core.Models;
|
namespace DiscordChatExporter.Domain.Exporting
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Rendering
|
|
||||||
{
|
{
|
||||||
public class RenderOptions
|
public class RenderOptions
|
||||||
{
|
{
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="chatlog__messages">
|
<div class="chatlog__messages">
|
||||||
{{~ # Author name and timestamp ~}}
|
{{~ # Author name and timestamp ~}}
|
||||||
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" style="color: {{ GetUserColor Context.Guild MessageGroup.Author }}">{{ GetUserNick Context.Guild MessageGroup.Author | html.escape }}</span>
|
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if GetUserColor Context.Guild MessageGroup.Author }} style="color: {{ GetUserColor Context.Guild MessageGroup.Author }}" {{ end }}>{{ GetUserNick Context.Guild MessageGroup.Author | html.escape }}</span>
|
||||||
|
|
||||||
{{~ # Bot tag ~}}
|
{{~ # Bot tag ~}}
|
||||||
{{~ if MessageGroup.Author.IsBot ~}}
|
{{~ if MessageGroup.Author.IsBot ~}}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,24 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Drawing;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Core.Rendering.Logic;
|
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||||
|
using DiscordChatExporter.Domain.Internal;
|
||||||
|
using DiscordChatExporter.Domain.Markdown;
|
||||||
|
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||||
using Scriban;
|
using Scriban;
|
||||||
using Scriban.Runtime;
|
using Scriban.Runtime;
|
||||||
using Tyrrrz.Extensions;
|
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 TextWriter _writer;
|
||||||
private readonly string _themeName;
|
private readonly string _themeName;
|
||||||
|
|
@ -70,10 +76,10 @@ namespace DiscordChatExporter.Core.Rendering.Formatters
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
scriptObject.Import("FormatDate",
|
scriptObject.Import("FormatDate",
|
||||||
new Func<DateTimeOffset, string>(d => SharedRenderingLogic.FormatDate(d, Context.DateFormat)));
|
new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.DateFormat)));
|
||||||
|
|
||||||
scriptObject.Import("FormatMarkdown",
|
scriptObject.Import("FormatMarkdown",
|
||||||
new Func<string, string>(m => HtmlRenderingLogic.FormatMarkdown(Context, m)));
|
new Func<string, string>(FormatMarkdown));
|
||||||
|
|
||||||
scriptObject.Import("GetUserColor", new Func<Guild, User, string>(Guild.GetUserColor));
|
scriptObject.Import("GetUserColor", new Func<Guild, User, string>(Guild.GetUserColor));
|
||||||
|
|
||||||
|
|
@ -88,6 +94,9 @@ namespace DiscordChatExporter.Core.Rendering.Formatters
|
||||||
return templateContext;
|
return templateContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string FormatMarkdown(string markdown) =>
|
||||||
|
HtmlMarkdownVisitor.Format(Context, markdown);
|
||||||
|
|
||||||
private async Task RenderCurrentMessageGroupAsync()
|
private async Task RenderCurrentMessageGroupAsync()
|
||||||
{
|
{
|
||||||
var templateContext = CreateTemplateContext(new Dictionary<string, object>
|
var templateContext = CreateTemplateContext(new Dictionary<string, object>
|
||||||
|
|
@ -107,7 +116,7 @@ namespace DiscordChatExporter.Core.Rendering.Formatters
|
||||||
public override async Task WriteMessageAsync(Message message)
|
public override async Task WriteMessageAsync(Message message)
|
||||||
{
|
{
|
||||||
// If message group is empty or the given message can be grouped, buffer the given 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);
|
_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 Assembly ResourcesAssembly = typeof(HtmlMessageWriter).Assembly;
|
||||||
private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Resources";
|
private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Exporting.Resources";
|
||||||
|
|
||||||
private static string GetCoreStyleSheetCode() =>
|
private static string GetCoreStyleSheetCode() =>
|
||||||
ResourcesAssembly
|
ResourcesAssembly
|
||||||
|
|
@ -171,5 +180,7 @@ namespace DiscordChatExporter.Core.Rendering.Formatters
|
||||||
ResourcesAssembly
|
ResourcesAssembly
|
||||||
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
|
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
|
||||||
.SubstringAfter("{{~ %SPLIT% ~}}");
|
.SubstringAfter("{{~ %SPLIT% ~}}");
|
||||||
|
|
||||||
|
private static string HtmlEncode(string s) => WebUtility.HtmlEncode(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Core.Rendering.Internal;
|
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||||
using DiscordChatExporter.Core.Rendering.Logic;
|
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;
|
private readonly Utf8JsonWriter _writer;
|
||||||
|
|
||||||
|
|
@ -66,7 +66,7 @@ namespace DiscordChatExporter.Core.Rendering.Formatters
|
||||||
_writer.WriteBoolean("isPinned", message.IsPinned);
|
_writer.WriteBoolean("isPinned", message.IsPinned);
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
var content = PlainTextRenderingLogic.FormatMessageContent(Context, message);
|
var content = PlainTextMarkdownVisitor.Format(Context, message.Content);
|
||||||
_writer.WriteString("content", content);
|
_writer.WriteString("content", content);
|
||||||
|
|
||||||
// Author
|
// Author
|
||||||
|
|
@ -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 => ("<strong>", "</strong>"),
|
||||||
|
TextFormatting.Italic => ("<em>", "</em>"),
|
||||||
|
TextFormatting.Underline => ("<u>", "</u>"),
|
||||||
|
TextFormatting.Strikethrough => ("<s>", "</s>"),
|
||||||
|
TextFormatting.Spoiler => (
|
||||||
|
"<span class=\"spoiler spoiler--hidden\" onclick=\"showSpoiler(event, this)\"><span class=\"spoiler-text\">", "</span>"),
|
||||||
|
TextFormatting.Quote => ("<div class=\"quote\">", "</div>"),
|
||||||
|
_ => 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("<span class=\"pre pre--inline\">")
|
||||||
|
.Append(HtmlEncode(inlineCodeBlock.Code))
|
||||||
|
.Append("</span>");
|
||||||
|
|
||||||
|
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
|
||||||
|
{
|
||||||
|
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
||||||
|
? $"language-{multiLineCodeBlock.Language}"
|
||||||
|
: "nohighlight";
|
||||||
|
|
||||||
|
_buffer
|
||||||
|
.Append($"<div class=\"pre pre--multiline {highlightCssClass}\">")
|
||||||
|
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
||||||
|
.Append("</div>");
|
||||||
|
|
||||||
|
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override MarkdownNode VisitMention(MentionNode mention)
|
||||||
|
{
|
||||||
|
if (mention.Type == MentionType.Meta)
|
||||||
|
{
|
||||||
|
_buffer
|
||||||
|
.Append("<span class=\"mention\">")
|
||||||
|
.Append("@").Append(HtmlEncode(mention.Id))
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
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($"<span class=\"mention\" title=\"{HtmlEncode(user.FullName)}\">")
|
||||||
|
.Append("@").Append(HtmlEncode(nick))
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
else if (mention.Type == MentionType.Channel)
|
||||||
|
{
|
||||||
|
var channel = _context.MentionableChannels.FirstOrDefault(c => c.Id == mention.Id) ??
|
||||||
|
Channel.CreateDeletedChannel(mention.Id);
|
||||||
|
|
||||||
|
_buffer
|
||||||
|
.Append("<span class=\"mention\">")
|
||||||
|
.Append("#").Append(HtmlEncode(channel.Name))
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
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($"<span class=\"mention\" style=\"{style}>\"")
|
||||||
|
.Append("@").Append(HtmlEncode(role.Name))
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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($"<img class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Name}\" src=\"{emojiImageUrl}\" />");
|
||||||
|
|
||||||
|
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($"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">")
|
||||||
|
.Append(HtmlEncode(link.Title))
|
||||||
|
.Append("</a>");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_buffer
|
||||||
|
.Append($"<a href=\"{Uri.EscapeUriString(link.Url)}\">")
|
||||||
|
.Append(HtmlEncode(link.Title))
|
||||||
|
.Append("</a>");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
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; }
|
protected Stream Stream { get; }
|
||||||
|
|
||||||
|
|
@ -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<Attachment> 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<Embed> 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<Reaction> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
DiscordChatExporter.Domain/Internal/ColorExtensions.cs
Normal file
15
DiscordChatExporter.Domain/Internal/ColorExtensions.cs
Normal file
|
|
@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services.Internal.Extensions
|
namespace DiscordChatExporter.Domain.Internal
|
||||||
{
|
{
|
||||||
internal static class DateExtensions
|
internal static class DateExtensions
|
||||||
{
|
{
|
||||||
|
|
@ -9,5 +10,8 @@ namespace DiscordChatExporter.Core.Services.Internal.Extensions
|
||||||
var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
|
var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
|
||||||
return value.ToString();
|
return value.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string ToLocalString(this DateTimeOffset dateTime, string format) =>
|
||||||
|
dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
14
DiscordChatExporter.Domain/Internal/GenericExtensions.cs
Normal file
14
DiscordChatExporter.Domain/Internal/GenericExtensions.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Internal
|
||||||
|
{
|
||||||
|
internal static class GenericExtensions
|
||||||
|
{
|
||||||
|
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
|
||||||
|
|
||||||
|
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct =>
|
||||||
|
!predicate(value)
|
||||||
|
? value
|
||||||
|
: (T?) null;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
DiscordChatExporter.Domain/Internal/HttpClientExtensions.cs
Normal file
17
DiscordChatExporter.Domain/Internal/HttpClientExtensions.cs
Normal file
|
|
@ -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<JsonElement> ReadAsJsonAsync(this HttpContent content)
|
||||||
|
{
|
||||||
|
await using var stream = await content.ReadAsStreamAsync();
|
||||||
|
using var doc = await JsonDocument.ParseAsync(stream);
|
||||||
|
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services.Internal.Extensions
|
namespace DiscordChatExporter.Domain.Internal
|
||||||
{
|
{
|
||||||
internal static class JsonElementExtensions
|
internal static class JsonElementExtensions
|
||||||
{
|
{
|
||||||
21
DiscordChatExporter.Domain/Internal/StringExtensions.cs
Normal file
21
DiscordChatExporter.Domain/Internal/StringExtensions.cs
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,25 +1,10 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
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)
|
public static void WriteString(this Utf8JsonWriter writer, string propertyName, DateTimeOffset? value)
|
||||||
{
|
{
|
||||||
writer.WritePropertyName(propertyName);
|
writer.WritePropertyName(propertyName);
|
||||||
|
|
@ -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; }
|
public string? Id { get; }
|
||||||
|
|
||||||
29
DiscordChatExporter.Domain/Markdown/Ast/FormattedNode.cs
Normal file
29
DiscordChatExporter.Domain/Markdown/Ast/FormattedNode.cs
Normal file
|
|
@ -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<MarkdownNode> Children { get; }
|
||||||
|
|
||||||
|
public FormattedNode(TextFormatting formatting, IReadOnlyList<MarkdownNode> children)
|
||||||
|
{
|
||||||
|
Formatting = formatting;
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"<{Formatting}> (+{Children.Count})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
public string Code { get; }
|
||||||
|
|
||||||
|
|
@ -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; }
|
public string Url { get; }
|
||||||
|
|
||||||
6
DiscordChatExporter.Domain/Markdown/Ast/MarkdownNode.cs
Normal file
6
DiscordChatExporter.Domain/Markdown/Ast/MarkdownNode.cs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||||
|
{
|
||||||
|
internal abstract class MarkdownNode
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
public string Id { get; }
|
||||||
|
|
||||||
|
|
@ -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; }
|
public string Language { get; }
|
||||||
|
|
||||||
|
|
@ -12,6 +12,6 @@
|
||||||
Code = code;
|
Code = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => $"<Code [{Language}]> {Code}";
|
public override string ToString() => $"<{Language}> {Code}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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; }
|
public string Text { get; }
|
||||||
|
|
||||||
|
|
@ -1,70 +1,70 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Markdown.Ast;
|
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||||
using DiscordChatExporter.Core.Markdown.Internal;
|
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
|
// 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;
|
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline;
|
||||||
|
|
||||||
/* Formatting */
|
/* Formatting */
|
||||||
|
|
||||||
// Capture any character until the earliest double asterisk not followed by an asterisk
|
// Capture any character until the earliest double asterisk not followed by an asterisk
|
||||||
private static readonly IMatcher<Node> BoldFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> BoldFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Bold, Parse(p.Slice(m.Groups[1]))));
|
(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
|
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
|
||||||
// Opening asterisk must not be followed by whitespace
|
// Opening asterisk must not be followed by whitespace
|
||||||
// Closing asterisk must not be preceded by whitespace
|
// Closing asterisk must not be preceded by whitespace
|
||||||
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> ItalicFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]))));
|
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]))));
|
||||||
|
|
||||||
// Capture any character until the earliest triple asterisk not followed by an asterisk
|
// Capture any character until the earliest triple asterisk not followed by an asterisk
|
||||||
private static readonly IMatcher<Node> ItalicBoldFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattedNodeMatcher)));
|
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattedNodeMatcher)));
|
||||||
|
|
||||||
// Capture any character except underscore until an underscore
|
// Capture any character except underscore until an underscore
|
||||||
// Closing underscore must not be followed by a word character
|
// Closing underscore must not be followed by a word character
|
||||||
private static readonly IMatcher<Node> ItalicAltFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> ItalicAltFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]))));
|
(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
|
// Capture any character until the earliest double underscore not followed by an underscore
|
||||||
private static readonly IMatcher<Node> UnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> UnderlineFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Underline, Parse(p.Slice(m.Groups[1]))));
|
(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
|
// Capture any character until the earliest triple underscore not followed by an underscore
|
||||||
private static readonly IMatcher<Node> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), UnderlineFormattedNodeMatcher)));
|
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Slice(m.Groups[1]), UnderlineFormattedNodeMatcher)));
|
||||||
|
|
||||||
// Capture any character until the earliest double tilde
|
// Capture any character until the earliest double tilde
|
||||||
private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> StrikethroughFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Strikethrough, Parse(p.Slice(m.Groups[1]))));
|
(p, m) => new FormattedNode(TextFormatting.Strikethrough, Parse(p.Slice(m.Groups[1]))));
|
||||||
|
|
||||||
// Capture any character until the earliest double pipe
|
// Capture any character until the earliest double pipe
|
||||||
private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> SpoilerFormattedNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Spoiler, Parse(p.Slice(m.Groups[1]))));
|
(p, m) => new FormattedNode(TextFormatting.Spoiler, Parse(p.Slice(m.Groups[1]))));
|
||||||
|
|
||||||
// Capture any character until the end of the line
|
// Capture any character until the end of the line
|
||||||
// Opening 'greater than' character must be followed by whitespace
|
// Opening 'greater than' character must be followed by whitespace
|
||||||
private static readonly IMatcher<Node> SingleLineQuoteNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("^>\\s(.+\n?)", DefaultRegexOptions),
|
new Regex("^>\\s(.+\n?)", DefaultRegexOptions),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1]))));
|
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1]))));
|
||||||
|
|
||||||
// Repeatedly capture any character until the end of the line
|
// 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
|
// This one is tricky as it ends up producing multiple separate captures which need to be joined
|
||||||
private static readonly IMatcher<Node> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions),
|
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions),
|
||||||
(p, m) =>
|
(p, m) =>
|
||||||
{
|
{
|
||||||
|
|
@ -74,7 +74,7 @@ namespace DiscordChatExporter.Core.Markdown
|
||||||
|
|
||||||
// Capture any character until the end of the input
|
// Capture any character until the end of the input
|
||||||
// Opening 'greater than' characters must be followed by whitespace
|
// Opening 'greater than' characters must be followed by whitespace
|
||||||
private static readonly IMatcher<Node> MultiLineQuoteNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Slice(m.Groups[1]))));
|
(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
|
// Capture any character except backtick until a backtick
|
||||||
// Blank lines at the beginning and end of content are trimmed
|
// Blank lines at the beginning and end of content are trimmed
|
||||||
private static readonly IMatcher<Node> InlineCodeBlockNodeMatcher = new RegexMatcher<Node>(
|
// There can be either one or two backticks, but equal number on both sides
|
||||||
|
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
m => new InlineCodeBlockNode(m.Groups[1].Value.Trim('\r', '\n')));
|
m => new InlineCodeBlockNode(m.Groups[1].Value.Trim('\r', '\n')));
|
||||||
|
|
||||||
// Capture language identifier and then any character until the earliest triple backtick
|
// 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
|
// Language identifier is one word immediately after opening backticks, followed immediately by newline
|
||||||
// Blank lines at the beginning and end of content are trimmed
|
// Blank lines at the beginning and end of content are trimmed
|
||||||
private static readonly IMatcher<Node> MultiLineCodeBlockNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')));
|
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')));
|
||||||
|
|
||||||
/* Mentions */
|
/* Mentions */
|
||||||
|
|
||||||
// Capture @everyone
|
// Capture @everyone
|
||||||
private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||||
"@everyone",
|
"@everyone",
|
||||||
p => new MentionNode("everyone", MentionType.Meta));
|
p => new MentionNode("everyone", MentionType.Meta));
|
||||||
|
|
||||||
// Capture @here
|
// Capture @here
|
||||||
private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||||
"@here",
|
"@here",
|
||||||
p => new MentionNode("here", MentionType.Meta));
|
p => new MentionNode("here", MentionType.Meta));
|
||||||
|
|
||||||
// Capture <@123456> or <@!123456>
|
// Capture <@123456> or <@!123456>
|
||||||
private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
||||||
m => new MentionNode(m.Groups[1].Value, MentionType.User));
|
m => new MentionNode(m.Groups[1].Value, MentionType.User));
|
||||||
|
|
||||||
// Capture <#123456>
|
// Capture <#123456>
|
||||||
private static readonly IMatcher<Node> ChannelMentionNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<#(\\d+)>", DefaultRegexOptions),
|
new Regex("<#(\\d+)>", DefaultRegexOptions),
|
||||||
m => new MentionNode(m.Groups[1].Value, MentionType.Channel));
|
m => new MentionNode(m.Groups[1].Value, MentionType.Channel));
|
||||||
|
|
||||||
// Capture <@&123456>
|
// Capture <@&123456>
|
||||||
private static readonly IMatcher<Node> RoleMentionNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<@&(\\d+)>", DefaultRegexOptions),
|
new Regex("<@&(\\d+)>", DefaultRegexOptions),
|
||||||
m => new MentionNode(m.Groups[1].Value, MentionType.Role));
|
m => new MentionNode(m.Groups[1].Value, MentionType.Role));
|
||||||
|
|
||||||
|
|
@ -127,29 +128,29 @@ namespace DiscordChatExporter.Core.Markdown
|
||||||
// ... or surrogate pair
|
// ... or surrogate pair
|
||||||
// ... or digit followed by enclosing mark
|
// ... or digit followed by enclosing mark
|
||||||
// (this does not match all emojis in Discord but it's reasonably accurate enough)
|
// (this does not match all emojis in Discord but it's reasonably accurate enough)
|
||||||
private static readonly IMatcher<Node> StandardEmojiNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
|
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
|
||||||
m => new EmojiNode(m.Groups[1].Value));
|
m => new EmojiNode(m.Groups[1].Value));
|
||||||
|
|
||||||
// Capture <:lul:123456> or <a:lul:123456>
|
// Capture <:lul:123456> or <a:lul:123456>
|
||||||
private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
|
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
|
||||||
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value)));
|
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value)));
|
||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
|
|
||||||
// Capture [title](link)
|
// Capture [title](link)
|
||||||
private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
||||||
m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value));
|
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
|
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
|
||||||
private static readonly IMatcher<Node> AutoLinkNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
|
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
|
||||||
m => new LinkNode(m.Groups[1].Value));
|
m => new LinkNode(m.Groups[1].Value));
|
||||||
|
|
||||||
// Same as auto link but also surrounded by angular brackets
|
// Same as auto link but also surrounded by angular brackets
|
||||||
private static readonly IMatcher<Node> HiddenLinkNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
|
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
|
||||||
m => new LinkNode(m.Groups[1].Value));
|
m => new LinkNode(m.Groups[1].Value));
|
||||||
|
|
||||||
|
|
@ -157,31 +158,31 @@ namespace DiscordChatExporter.Core.Markdown
|
||||||
|
|
||||||
// Capture the shrug emoticon
|
// Capture the shrug emoticon
|
||||||
// This escapes it from matching for formatting
|
// This escapes it from matching for formatting
|
||||||
private static readonly IMatcher<Node> ShrugTextNodeMatcher = new StringMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||||
@"¯\_(ツ)_/¯",
|
@"¯\_(ツ)_/¯",
|
||||||
p => new TextNode(p.ToString()));
|
p => new TextNode(p.ToString()));
|
||||||
|
|
||||||
// Capture some specific emojis that don't get rendered
|
// Capture some specific emojis that don't get rendered
|
||||||
// This escapes it from matching for emoji
|
// This escapes it from matching for emoji
|
||||||
private static readonly IMatcher<Node> IgnoredEmojiTextNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
|
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
|
||||||
m => new TextNode(m.Groups[1].Value));
|
m => new TextNode(m.Groups[1].Value));
|
||||||
|
|
||||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
|
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
|
||||||
// This escapes it from matching for emoji
|
// This escapes it from matching for emoji
|
||||||
private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
|
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
|
||||||
m => new TextNode(m.Groups[1].Value));
|
m => new TextNode(m.Groups[1].Value));
|
||||||
|
|
||||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
|
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
|
||||||
// This escapes it from matching for formatting or other tokens
|
// This escapes it from matching for formatting or other tokens
|
||||||
private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
|
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
|
||||||
m => new TextNode(m.Groups[1].Value));
|
m => new TextNode(m.Groups[1].Value));
|
||||||
|
|
||||||
// Combine all matchers into one
|
// Combine all matchers into one
|
||||||
// Matchers that have similar patterns are ordered from most specific to least specific
|
// Matchers that have similar patterns are ordered from most specific to least specific
|
||||||
private static readonly IMatcher<Node> AggregateNodeMatcher = new AggregateMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> AggregateNodeMatcher = new AggregateMatcher<MarkdownNode>(
|
||||||
// Escaped text
|
// Escaped text
|
||||||
ShrugTextNodeMatcher,
|
ShrugTextNodeMatcher,
|
||||||
IgnoredEmojiTextNodeMatcher,
|
IgnoredEmojiTextNodeMatcher,
|
||||||
|
|
@ -223,7 +224,7 @@ namespace DiscordChatExporter.Core.Markdown
|
||||||
);
|
);
|
||||||
|
|
||||||
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
||||||
private static readonly IMatcher<Node> MinimalAggregateNodeMatcher = new AggregateMatcher<Node>(
|
private static readonly IMatcher<MarkdownNode> MinimalAggregateNodeMatcher = new AggregateMatcher<MarkdownNode>(
|
||||||
// Mentions
|
// Mentions
|
||||||
EveryoneMentionNodeMatcher,
|
EveryoneMentionNodeMatcher,
|
||||||
HereMentionNodeMatcher,
|
HereMentionNodeMatcher,
|
||||||
|
|
@ -235,15 +236,21 @@ namespace DiscordChatExporter.Core.Markdown
|
||||||
CustomEmojiNodeMatcher
|
CustomEmojiNodeMatcher
|
||||||
);
|
);
|
||||||
|
|
||||||
private static IReadOnlyList<Node> Parse(StringPart stringPart, IMatcher<Node> matcher) =>
|
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart, IMatcher<MarkdownNode> matcher) =>
|
||||||
matcher.MatchAll(stringPart, p => new TextNode(p.ToString())).Select(r => r.Value).ToArray();
|
matcher
|
||||||
|
.MatchAll(stringPart, p => new TextNode(p.ToString()))
|
||||||
|
.Select(r => r.Value)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<Node> Parse(StringPart stringPart) => Parse(stringPart, AggregateNodeMatcher);
|
internal static partial class MarkdownParser
|
||||||
|
{
|
||||||
|
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart) => Parse(stringPart, AggregateNodeMatcher);
|
||||||
|
|
||||||
private static IReadOnlyList<Node> ParseMinimal(StringPart stringPart) => Parse(stringPart, MinimalAggregateNodeMatcher);
|
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringPart stringPart) => Parse(stringPart, MinimalAggregateNodeMatcher);
|
||||||
|
|
||||||
public static IReadOnlyList<Node> Parse(string input) => Parse(new StringPart(input));
|
public static IReadOnlyList<MarkdownNode> Parse(string input) => Parse(new StringPart(input));
|
||||||
|
|
||||||
public static IReadOnlyList<Node> ParseMinimal(string input) => ParseMinimal(new StringPart(input));
|
public static IReadOnlyList<MarkdownNode> ParseMinimal(string input) => ParseMinimal(new StringPart(input));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
45
DiscordChatExporter.Domain/Markdown/MarkdownVisitor.cs
Normal file
45
DiscordChatExporter.Domain/Markdown/MarkdownVisitor.cs
Normal file
|
|
@ -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<MarkdownNode> nodes)
|
||||||
|
{
|
||||||
|
foreach (var node in nodes)
|
||||||
|
Visit(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||||
{
|
{
|
||||||
internal class AggregateMatcher<T> : IMatcher<T>
|
internal class AggregateMatcher<T> : IMatcher<T>
|
||||||
{
|
{
|
||||||
|
|
@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParsedMatch<T>? Match(StringPart stringPart)
|
public ParsedMatch<T>? TryMatch(StringPart stringPart)
|
||||||
{
|
{
|
||||||
ParsedMatch<T>? earliestMatch = null;
|
ParsedMatch<T>? earliestMatch = null;
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
foreach (var matcher in _matchers)
|
foreach (var matcher in _matchers)
|
||||||
{
|
{
|
||||||
// Try to match
|
// Try to match
|
||||||
var match = matcher.Match(stringPart);
|
var match = matcher.TryMatch(stringPart);
|
||||||
|
|
||||||
// If there's no match - continue
|
// If there's no match - continue
|
||||||
if (match == null)
|
if (match == null)
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue