mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-02-14 15:53:30 -07:00
C#10ify
This commit is contained in:
parent
8e7baee8a5
commit
880f400e2c
|
|
@ -14,119 +14,118 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using JsonExtensions;
|
using JsonExtensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Fixtures
|
namespace DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
|
||||||
|
public class ExportWrapperFixture : IDisposable
|
||||||
{
|
{
|
||||||
public class ExportWrapperFixture : IDisposable
|
private string DirPath { get; } = Path.Combine(
|
||||||
|
Path.GetDirectoryName(typeof(ExportWrapperFixture).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
||||||
|
"ExportCache",
|
||||||
|
Guid.NewGuid().ToString()
|
||||||
|
);
|
||||||
|
|
||||||
|
public ExportWrapperFixture() => DirectoryEx.Reset(DirPath);
|
||||||
|
|
||||||
|
private async ValueTask<string> ExportAsync(Snowflake channelId, ExportFormat format)
|
||||||
{
|
{
|
||||||
private string DirPath { get; } = Path.Combine(
|
var fileName = channelId.ToString() + '.' + format.GetFileExtension();
|
||||||
Path.GetDirectoryName(typeof(ExportWrapperFixture).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
var filePath = Path.Combine(DirPath, fileName);
|
||||||
"ExportCache",
|
|
||||||
Guid.NewGuid().ToString()
|
// Perform export only if it hasn't been done before
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
TokenValue = Secrets.DiscordToken,
|
||||||
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
|
ChannelIds = new[] { channelId },
|
||||||
|
ExportFormat = format,
|
||||||
|
OutputPath = filePath
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
}
|
||||||
|
|
||||||
|
return await File.ReadAllTextAsync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId)
|
||||||
|
{
|
||||||
|
var data = await ExportAsync(channelId, ExportFormat.HtmlDark);
|
||||||
|
return Html.Parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId)
|
||||||
|
{
|
||||||
|
var data = await ExportAsync(channelId, ExportFormat.Json);
|
||||||
|
return Json.Parse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId)
|
||||||
|
{
|
||||||
|
var data = await ExportAsync(channelId, ExportFormat.PlainText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> ExportAsCsvAsync(Snowflake channelId)
|
||||||
|
{
|
||||||
|
var data = await ExportAsync(channelId, ExportFormat.Csv);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId)
|
||||||
|
{
|
||||||
|
var document = await ExportAsHtmlAsync(channelId);
|
||||||
|
return document.QuerySelectorAll("[data-message-id]").ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId)
|
||||||
|
{
|
||||||
|
var document = await ExportAsJsonAsync(channelId);
|
||||||
|
return document.GetProperty("messages").EnumerateArray().ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
|
||||||
|
{
|
||||||
|
var messages = await GetMessagesAsHtmlAsync(channelId);
|
||||||
|
|
||||||
|
var message = messages.SingleOrDefault(e =>
|
||||||
|
string.Equals(
|
||||||
|
e.GetAttribute("data-message-id"),
|
||||||
|
messageId.ToString(),
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
public ExportWrapperFixture() => DirectoryEx.Reset(DirPath);
|
if (message is null)
|
||||||
|
|
||||||
private async ValueTask<string> ExportAsync(Snowflake channelId, ExportFormat format)
|
|
||||||
{
|
{
|
||||||
var fileName = channelId.ToString() + '.' + format.GetFileExtension();
|
throw new InvalidOperationException(
|
||||||
var filePath = Path.Combine(DirPath, fileName);
|
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
||||||
|
|
||||||
// Perform export only if it hasn't been done before
|
|
||||||
if (!File.Exists(filePath))
|
|
||||||
{
|
|
||||||
await new ExportChannelsCommand
|
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { channelId },
|
|
||||||
ExportFormat = format,
|
|
||||||
OutputPath = filePath
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
}
|
|
||||||
|
|
||||||
return await File.ReadAllTextAsync(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId)
|
|
||||||
{
|
|
||||||
var data = await ExportAsync(channelId, ExportFormat.HtmlDark);
|
|
||||||
return Html.Parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId)
|
|
||||||
{
|
|
||||||
var data = await ExportAsync(channelId, ExportFormat.Json);
|
|
||||||
return Json.Parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId)
|
|
||||||
{
|
|
||||||
var data = await ExportAsync(channelId, ExportFormat.PlainText);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ExportAsCsvAsync(Snowflake channelId)
|
|
||||||
{
|
|
||||||
var data = await ExportAsync(channelId, ExportFormat.Csv);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId)
|
|
||||||
{
|
|
||||||
var document = await ExportAsHtmlAsync(channelId);
|
|
||||||
return document.QuerySelectorAll("[data-message-id]").ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId)
|
|
||||||
{
|
|
||||||
var document = await ExportAsJsonAsync(channelId);
|
|
||||||
return document.GetProperty("messages").EnumerateArray().ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
|
|
||||||
{
|
|
||||||
var messages = await GetMessagesAsHtmlAsync(channelId);
|
|
||||||
|
|
||||||
var message = messages.SingleOrDefault(e =>
|
|
||||||
string.Equals(
|
|
||||||
e.GetAttribute("data-message-id"),
|
|
||||||
messageId.ToString(),
|
|
||||||
StringComparison.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (message is null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
|
return message;
|
||||||
{
|
|
||||||
var messages = await GetMessagesAsJsonAsync(channelId);
|
|
||||||
|
|
||||||
var message = messages.FirstOrDefault(j =>
|
|
||||||
string.Equals(
|
|
||||||
j.GetProperty("id").GetString(),
|
|
||||||
messageId.ToString(),
|
|
||||||
StringComparison.OrdinalIgnoreCase
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (message.ValueKind == JsonValueKind.Undefined)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() => DirectoryEx.DeleteIfExists(DirPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
|
||||||
|
{
|
||||||
|
var messages = await GetMessagesAsJsonAsync(channelId);
|
||||||
|
|
||||||
|
var message = messages.FirstOrDefault(j =>
|
||||||
|
string.Equals(
|
||||||
|
j.GetProperty("id").GetString(),
|
||||||
|
messageId.ToString(),
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (message.ValueKind == JsonValueKind.Undefined)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => DirectoryEx.DeleteIfExists(DirPath);
|
||||||
}
|
}
|
||||||
|
|
@ -2,22 +2,21 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using DiscordChatExporter.Cli.Tests.Utils;
|
using DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Fixtures
|
namespace DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
|
||||||
|
public class TempOutputFixture : IDisposable
|
||||||
{
|
{
|
||||||
public class TempOutputFixture : IDisposable
|
public string DirPath { get; } = Path.Combine(
|
||||||
{
|
Path.GetDirectoryName(typeof(TempOutputFixture).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
||||||
public string DirPath { get; } = Path.Combine(
|
"Temp",
|
||||||
Path.GetDirectoryName(typeof(TempOutputFixture).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
Guid.NewGuid().ToString()
|
||||||
"Temp",
|
);
|
||||||
Guid.NewGuid().ToString()
|
|
||||||
);
|
|
||||||
|
|
||||||
public TempOutputFixture() => DirectoryEx.Reset(DirPath);
|
public TempOutputFixture() => DirectoryEx.Reset(DirPath);
|
||||||
|
|
||||||
public string GetTempFilePath(string fileName) => Path.Combine(DirPath, fileName);
|
public string GetTempFilePath(string fileName) => Path.Combine(DirPath, fileName);
|
||||||
|
|
||||||
public string GetTempFilePath() => GetTempFilePath(Guid.NewGuid() + ".tmp");
|
public string GetTempFilePath() => GetTempFilePath(Guid.NewGuid() + ".tmp");
|
||||||
|
|
||||||
public void Dispose() => DirectoryEx.DeleteIfExists(DirPath);
|
public void Dispose() => DirectoryEx.DeleteIfExists(DirPath);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,46 +1,45 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Infra
|
namespace DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
|
||||||
|
internal static class Secrets
|
||||||
{
|
{
|
||||||
internal static class Secrets
|
private static readonly Lazy<string> DiscordTokenLazy = new(() =>
|
||||||
{
|
{
|
||||||
private static readonly Lazy<string> DiscordTokenLazy = new(() =>
|
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN");
|
||||||
{
|
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
||||||
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN");
|
return fromEnvironment;
|
||||||
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
|
||||||
return fromEnvironment;
|
|
||||||
|
|
||||||
var secretFilePath = Path.Combine(
|
var secretFilePath = Path.Combine(
|
||||||
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
||||||
"DiscordToken.secret"
|
"DiscordToken.secret"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (File.Exists(secretFilePath))
|
if (File.Exists(secretFilePath))
|
||||||
return File.ReadAllText(secretFilePath);
|
return File.ReadAllText(secretFilePath);
|
||||||
|
|
||||||
throw new InvalidOperationException("Discord token not provided for tests.");
|
throw new InvalidOperationException("Discord token not provided for tests.");
|
||||||
});
|
});
|
||||||
|
|
||||||
private static readonly Lazy<bool> IsDiscordTokenBotLazy = new(() =>
|
private static readonly Lazy<bool> IsDiscordTokenBotLazy = new(() =>
|
||||||
{
|
{
|
||||||
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT");
|
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT");
|
||||||
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
||||||
return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase);
|
return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var secretFilePath = Path.Combine(
|
var secretFilePath = Path.Combine(
|
||||||
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
||||||
"DiscordTokenBot.secret"
|
"DiscordTokenBot.secret"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (File.Exists(secretFilePath))
|
if (File.Exists(secretFilePath))
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
public static string DiscordToken => DiscordTokenLazy.Value;
|
public static string DiscordToken => DiscordTokenLazy.Value;
|
||||||
|
|
||||||
public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
|
public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -4,28 +4,27 @@ using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.CsvWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.CsvWriting;
|
||||||
{
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Messages_are_exported_correctly()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
|
||||||
|
|
||||||
// Assert
|
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
document.Should().ContainAll(
|
{
|
||||||
"Tyrrrz#5447",
|
[Fact]
|
||||||
"Hello world",
|
public async Task Messages_are_exported_correctly()
|
||||||
"Goodbye world",
|
{
|
||||||
"Foo bar",
|
// Act
|
||||||
"Hurdle Durdle",
|
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
||||||
"One",
|
|
||||||
"Two",
|
// Assert
|
||||||
"Three",
|
document.Should().ContainAll(
|
||||||
"Yeet"
|
"Tyrrrz#5447",
|
||||||
);
|
"Hello world",
|
||||||
}
|
"Goodbye world",
|
||||||
|
"Foo bar",
|
||||||
|
"Hurdle Durdle",
|
||||||
|
"One",
|
||||||
|
"Two",
|
||||||
|
"Three",
|
||||||
|
"Yeet"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13,148 +13,147 @@ using FluentAssertions;
|
||||||
using JsonExtensions;
|
using JsonExtensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
[Fact]
|
||||||
|
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Arrange
|
||||||
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date()
|
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
After = Snowflake.FromDate(after)
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
await new ExportChannelsCommand
|
var document = Json.Parse(data);
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
|
||||||
ExportFormat = ExportFormat.Json,
|
|
||||||
OutputPath = filePath,
|
|
||||||
After = Snowflake.FromDate(after)
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
var timestamps = document
|
||||||
var document = Json.Parse(data);
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
var timestamps = document
|
// Assert
|
||||||
.GetProperty("messages")
|
timestamps.All(t => t > after).Should().BeTrue();
|
||||||
.EnumerateArray()
|
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// Assert
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
timestamps.All(t => t > after).Should().BeTrue();
|
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
|
||||||
{
|
|
||||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
|
||||||
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
|
|
||||||
}, o =>
|
|
||||||
{
|
|
||||||
return o
|
|
||||||
.Using<DateTimeOffset>(ctx =>
|
|
||||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
|
||||||
)
|
|
||||||
.WhenTypeIs<DateTimeOffset>();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Messages_filtered_before_specific_date_only_include_messages_sent_before_that_date()
|
|
||||||
{
|
{
|
||||||
// Arrange
|
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||||
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||||
|
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||||
// Act
|
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
|
||||||
await new ExportChannelsCommand
|
}, o =>
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
|
||||||
ExportFormat = ExportFormat.Json,
|
|
||||||
OutputPath = filePath,
|
|
||||||
Before = Snowflake.FromDate(before)
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
var timestamps = document
|
|
||||||
.GetProperty("messages")
|
|
||||||
.EnumerateArray()
|
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
timestamps.All(t => t < before).Should().BeTrue();
|
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
|
||||||
{
|
|
||||||
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
|
|
||||||
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
|
|
||||||
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
|
|
||||||
}, o =>
|
|
||||||
{
|
|
||||||
return o
|
|
||||||
.Using<DateTimeOffset>(ctx =>
|
|
||||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
|
||||||
)
|
|
||||||
.WhenTypeIs<DateTimeOffset>();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Messages_filtered_between_specific_dates_only_include_messages_sent_between_those_dates()
|
|
||||||
{
|
{
|
||||||
// Arrange
|
return o
|
||||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
.Using<DateTimeOffset>(ctx =>
|
||||||
var before = new DateTimeOffset(2021, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
)
|
||||||
|
.WhenTypeIs<DateTimeOffset>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Act
|
[Fact]
|
||||||
await new ExportChannelsCommand
|
public async Task Messages_filtered_before_specific_date_only_include_messages_sent_before_that_date()
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
// Arrange
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
ExportFormat = ExportFormat.Json,
|
|
||||||
OutputPath = filePath,
|
|
||||||
Before = Snowflake.FromDate(before),
|
|
||||||
After = Snowflake.FromDate(after)
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Act
|
||||||
var document = Json.Parse(data);
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
TokenValue = Secrets.DiscordToken,
|
||||||
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
Before = Snowflake.FromDate(before)
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var timestamps = document
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
.GetProperty("messages")
|
var document = Json.Parse(data);
|
||||||
.EnumerateArray()
|
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// Assert
|
var timestamps = document
|
||||||
timestamps.All(t => t < before && t > after).Should().BeTrue();
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
// Assert
|
||||||
{
|
timestamps.All(t => t < before).Should().BeTrue();
|
||||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
{
|
||||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
|
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
|
||||||
}, o =>
|
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
|
||||||
{
|
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
|
||||||
return o
|
}, o =>
|
||||||
.Using<DateTimeOffset>(ctx =>
|
{
|
||||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
return o
|
||||||
)
|
.Using<DateTimeOffset>(ctx =>
|
||||||
.WhenTypeIs<DateTimeOffset>();
|
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||||
});
|
)
|
||||||
}
|
.WhenTypeIs<DateTimeOffset>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Messages_filtered_between_specific_dates_only_include_messages_sent_between_those_dates()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var before = new DateTimeOffset(2021, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
TokenValue = Secrets.DiscordToken,
|
||||||
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
Before = Snowflake.FromDate(before),
|
||||||
|
After = Snowflake.FromDate(after)
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
|
var document = Json.Parse(data);
|
||||||
|
|
||||||
|
var timestamps = document
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
timestamps.All(t => t < before && t > after).Should().BeTrue();
|
||||||
|
|
||||||
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
|
{
|
||||||
|
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||||
|
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||||
|
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||||
|
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
|
||||||
|
}, o =>
|
||||||
|
{
|
||||||
|
return o
|
||||||
|
.Using<DateTimeOffset>(ctx =>
|
||||||
|
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||||
|
)
|
||||||
|
.WhenTypeIs<DateTimeOffset>();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,124 +12,123 @@ using FluentAssertions;
|
||||||
using JsonExtensions;
|
using JsonExtensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
[Fact]
|
||||||
|
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Arrange
|
||||||
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text()
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
MessageFilter = MessageFilter.Parse("some text")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
await new ExportChannelsCommand
|
var document = Json.Parse(data);
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
|
||||||
ExportFormat = ExportFormat.Json,
|
|
||||||
OutputPath = filePath,
|
|
||||||
MessageFilter = MessageFilter.Parse("some text")
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
document
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
|
.Should()
|
||||||
|
.ContainSingle("Some random text");
|
||||||
|
}
|
||||||
|
|
||||||
// Assert
|
[Fact]
|
||||||
document
|
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author()
|
||||||
.GetProperty("messages")
|
{
|
||||||
.EnumerateArray()
|
// Arrange
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
.Should()
|
|
||||||
.ContainSingle("Some random text");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author()
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
await new ExportChannelsCommand
|
var document = Json.Parse(data);
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
|
||||||
ExportFormat = ExportFormat.Json,
|
|
||||||
OutputPath = filePath,
|
|
||||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
document
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
||||||
|
.Should()
|
||||||
|
.AllBe("Tyrrrz");
|
||||||
|
}
|
||||||
|
|
||||||
// Assert
|
[Fact]
|
||||||
document
|
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content()
|
||||||
.GetProperty("messages")
|
{
|
||||||
.EnumerateArray()
|
// Arrange
|
||||||
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
.Should()
|
|
||||||
.AllBe("Tyrrrz");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content()
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
MessageFilter = MessageFilter.Parse("has:image")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
await new ExportChannelsCommand
|
var document = Json.Parse(data);
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
|
||||||
ExportFormat = ExportFormat.Json,
|
|
||||||
OutputPath = filePath,
|
|
||||||
MessageFilter = MessageFilter.Parse("has:image")
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
document
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
|
.Should()
|
||||||
|
.ContainSingle("This has image");
|
||||||
|
}
|
||||||
|
|
||||||
// Assert
|
[Fact]
|
||||||
document
|
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
|
||||||
.GetProperty("messages")
|
{
|
||||||
.EnumerateArray()
|
// Arrange
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
.Should()
|
|
||||||
.ContainSingle("This has image");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
await new ExportChannelsCommand
|
var document = Json.Parse(data);
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
|
||||||
ExportFormat = ExportFormat.Json,
|
|
||||||
OutputPath = filePath,
|
|
||||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
document
|
||||||
|
.GetProperty("messages")
|
||||||
// Assert
|
.EnumerateArray()
|
||||||
document
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
.GetProperty("messages")
|
.Should()
|
||||||
.EnumerateArray()
|
.ContainSingle("This has mention");
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
|
||||||
.Should()
|
|
||||||
.ContainSingle("This has mention");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,88 +6,87 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
{
|
ChannelIds.AttachmentTestCases,
|
||||||
// Act
|
Snowflake.Parse("885587844989612074")
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
);
|
||||||
ChannelIds.AttachmentTestCases,
|
|
||||||
Snowflake.Parse("885587844989612074")
|
|
||||||
);
|
|
||||||
|
|
||||||
var fileUrl = message.QuerySelector("a")?.GetAttribute("href");
|
var fileUrl = message.QuerySelector("a")?.GetAttribute("href");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().ContainAll(
|
message.Text().Should().ContainAll(
|
||||||
"Generic file attachment",
|
"Generic file attachment",
|
||||||
"Test.txt",
|
"Test.txt",
|
||||||
"11 bytes"
|
"11 bytes"
|
||||||
);
|
);
|
||||||
|
|
||||||
fileUrl.Should().StartWithEquivalentOf(
|
fileUrl.Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885654862656843786")
|
Snowflake.Parse("885654862656843786")
|
||||||
);
|
);
|
||||||
|
|
||||||
var imageUrl = message.QuerySelector("img")?.GetAttribute("src");
|
var imageUrl = message.QuerySelector("img")?.GetAttribute("src");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Image attachment");
|
message.Text().Should().Contain("Image attachment");
|
||||||
|
|
||||||
imageUrl.Should().StartWithEquivalentOf(
|
imageUrl.Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885655761919836171")
|
Snowflake.Parse("885655761919836171")
|
||||||
);
|
);
|
||||||
|
|
||||||
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
|
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Video attachment");
|
message.Text().Should().Contain("Video attachment");
|
||||||
|
|
||||||
videoUrl.Should().StartWithEquivalentOf(
|
videoUrl.Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885656175620808734")
|
Snowflake.Parse("885656175620808734")
|
||||||
);
|
);
|
||||||
|
|
||||||
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
|
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Audio attachment");
|
message.Text().Should().Contain("Audio attachment");
|
||||||
|
|
||||||
audioUrl.Should().StartWithEquivalentOf(
|
audioUrl.Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,38 +6,37 @@ using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Messages_are_exported_correctly()
|
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal(
|
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal(
|
||||||
"866674314627121232",
|
"866674314627121232",
|
||||||
"866710679758045195",
|
"866710679758045195",
|
||||||
"866732113319428096",
|
"866732113319428096",
|
||||||
"868490009366396958",
|
"868490009366396958",
|
||||||
"868505966528835604",
|
"868505966528835604",
|
||||||
"868505969821364245",
|
"868505969821364245",
|
||||||
"868505973294268457",
|
"868505973294268457",
|
||||||
"885169254029213696"
|
"885169254029213696"
|
||||||
);
|
);
|
||||||
|
|
||||||
messages.Select(e => e.QuerySelector(".chatlog__content")?.Text().Trim()).Should().Equal(
|
messages.Select(e => e.QuerySelector(".chatlog__content")?.Text().Trim()).Should().Equal(
|
||||||
"Hello world",
|
"Hello world",
|
||||||
"Goodbye world",
|
"Goodbye world",
|
||||||
"Foo bar",
|
"Foo bar",
|
||||||
"Hurdle Durdle",
|
"Hurdle Durdle",
|
||||||
"One",
|
"One",
|
||||||
"Two",
|
"Two",
|
||||||
"Three",
|
"Three",
|
||||||
"Yeet"
|
"Yeet"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,59 +6,58 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task Message_with_an_embed_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Message_with_an_embed_is_rendered_correctly()
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
{
|
ChannelIds.EmbedTestCases,
|
||||||
// Act
|
Snowflake.Parse("866769910729146400")
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
);
|
||||||
ChannelIds.EmbedTestCases,
|
|
||||||
Snowflake.Parse("866769910729146400")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().ContainAll(
|
message.Text().Should().ContainAll(
|
||||||
"Embed author",
|
"Embed author",
|
||||||
"Embed title",
|
"Embed title",
|
||||||
"Embed description",
|
"Embed description",
|
||||||
"Field 1", "Value 1",
|
"Field 1", "Value 1",
|
||||||
"Field 2", "Value 2",
|
"Field 2", "Value 2",
|
||||||
"Field 3", "Value 3",
|
"Field 3", "Value 3",
|
||||||
"Embed footer"
|
"Embed footer"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_Spotify_track_is_rendered_using_an_iframe()
|
public async Task Message_with_a_Spotify_track_is_rendered_using_an_iframe()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("867886632203976775")
|
Snowflake.Parse("867886632203976775")
|
||||||
);
|
);
|
||||||
|
|
||||||
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
iframeSrc.Should().StartWithEquivalentOf("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
iframeSrc.Should().StartWithEquivalentOf("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_YouTube_video_is_rendered_using_an_iframe()
|
public async Task Message_with_a_YouTube_video_is_rendered_using_an_iframe()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("866472508588294165")
|
Snowflake.Parse("866472508588294165")
|
||||||
);
|
);
|
||||||
|
|
||||||
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
iframeSrc.Should().StartWithEquivalentOf("https://www.youtube.com/embed/qOWW4OlgbvE");
|
iframeSrc.Should().StartWithEquivalentOf("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,61 +6,60 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task User_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task User_mention_is_rendered_correctly()
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
{
|
ChannelIds.MentionTestCases,
|
||||||
// Act
|
Snowflake.Parse("866458840245076028")
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
);
|
||||||
ChannelIds.MentionTestCases,
|
|
||||||
Snowflake.Parse("866458840245076028")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("User mention: @Tyrrrz");
|
message.Text().Trim().Should().Be("User mention: @Tyrrrz");
|
||||||
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Text_channel_mention_is_rendered_correctly()
|
public async Task Text_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459040480624680")
|
Snowflake.Parse("866459040480624680")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Text channel mention: #mention-tests");
|
message.Text().Trim().Should().Be("Text channel mention: #mention-tests");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Voice_channel_mention_is_rendered_correctly()
|
public async Task Voice_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459175462633503")
|
Snowflake.Parse("866459175462633503")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc");
|
message.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Role_mention_is_rendered_correctly()
|
public async Task Role_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459254693429258")
|
Snowflake.Parse("866459254693429258")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Role mention: @Role 1");
|
message.Text().Trim().Should().Be("Role mention: @Role 1");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,52 +6,51 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task Reply_to_a_normal_message_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Reply_to_a_normal_message_is_rendered_correctly()
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
{
|
ChannelIds.ReplyTestCases,
|
||||||
// Act
|
Snowflake.Parse("866460738239725598")
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
);
|
||||||
ChannelIds.ReplyTestCases,
|
|
||||||
Snowflake.Parse("866460738239725598")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to original");
|
message.Text().Trim().Should().Be("reply to original");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original");
|
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_deleted_message_is_rendered_correctly()
|
public async Task Reply_to_a_deleted_message_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866460975388819486")
|
Snowflake.Parse("866460975388819486")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to deleted");
|
message.Text().Trim().Should().Be("reply to deleted");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
||||||
.Be("Original message was deleted or could not be loaded.");
|
.Be("Original message was deleted or could not be loaded.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_empty_message_with_attachment_is_rendered_correctly()
|
public async Task Reply_to_a_empty_message_with_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866462470335627294")
|
Snowflake.Parse("866462470335627294")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to attachment");
|
message.Text().Trim().Should().Be("reply to attachment");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
||||||
.Be("Click to see attachment 🖼️");
|
.Be("Click to see attachment 🖼️");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,96 +6,95 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
|
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
{
|
ChannelIds.AttachmentTestCases,
|
||||||
// Act
|
Snowflake.Parse("885587844989612074")
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
);
|
||||||
ChannelIds.AttachmentTestCases,
|
|
||||||
Snowflake.Parse("885587844989612074")
|
|
||||||
);
|
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Generic file attachment");
|
message.GetProperty("content").GetString().Should().Be("Generic file attachment");
|
||||||
|
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
|
||||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
|
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885654862656843786")
|
Snowflake.Parse("885654862656843786")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Image attachment");
|
message.GetProperty("content").GetString().Should().Be("Image attachment");
|
||||||
|
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
||||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
|
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885655761919836171")
|
Snowflake.Parse("885655761919836171")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Video attachment");
|
message.GetProperty("content").GetString().Should().Be("Video attachment");
|
||||||
|
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
||||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
|
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885656175620808734")
|
Snowflake.Parse("885656175620808734")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Audio attachment");
|
message.GetProperty("content").GetString().Should().Be("Audio attachment");
|
||||||
|
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
||||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
|
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,38 +5,37 @@ using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
|
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Messages_are_exported_correctly()
|
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal(
|
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal(
|
||||||
"866674314627121232",
|
"866674314627121232",
|
||||||
"866710679758045195",
|
"866710679758045195",
|
||||||
"866732113319428096",
|
"866732113319428096",
|
||||||
"868490009366396958",
|
"868490009366396958",
|
||||||
"868505966528835604",
|
"868505966528835604",
|
||||||
"868505969821364245",
|
"868505969821364245",
|
||||||
"868505973294268457",
|
"868505973294268457",
|
||||||
"885169254029213696"
|
"885169254029213696"
|
||||||
);
|
);
|
||||||
|
|
||||||
messages.Select(j => j.GetProperty("content").GetString()).Should().Equal(
|
messages.Select(j => j.GetProperty("content").GetString()).Should().Equal(
|
||||||
"Hello world",
|
"Hello world",
|
||||||
"Goodbye world",
|
"Goodbye world",
|
||||||
"Foo bar",
|
"Foo bar",
|
||||||
"Hurdle Durdle",
|
"Hurdle Durdle",
|
||||||
"One",
|
"One",
|
||||||
"Two",
|
"Two",
|
||||||
"Three",
|
"Three",
|
||||||
"Yeet"
|
"Yeet"
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,53 +6,52 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
|
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task Message_with_an_embed_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Message_with_an_embed_is_rendered_correctly()
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
{
|
ChannelIds.EmbedTestCases,
|
||||||
// Act
|
Snowflake.Parse("866769910729146400")
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
);
|
||||||
ChannelIds.EmbedTestCases,
|
|
||||||
Snowflake.Parse("866769910729146400")
|
|
||||||
);
|
|
||||||
|
|
||||||
var embed = message
|
var embed = message
|
||||||
.GetProperty("embeds")
|
.GetProperty("embeds")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Single();
|
.Single();
|
||||||
|
|
||||||
var embedAuthor = embed.GetProperty("author");
|
var embedAuthor = embed.GetProperty("author");
|
||||||
var embedThumbnail = embed.GetProperty("thumbnail");
|
var embedThumbnail = embed.GetProperty("thumbnail");
|
||||||
var embedFooter = embed.GetProperty("footer");
|
var embedFooter = embed.GetProperty("footer");
|
||||||
var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
|
var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
embed.GetProperty("title").GetString().Should().Be("Embed title");
|
embed.GetProperty("title").GetString().Should().Be("Embed title");
|
||||||
embed.GetProperty("url").GetString().Should().Be("https://example.com");
|
embed.GetProperty("url").GetString().Should().Be("https://example.com");
|
||||||
embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
|
embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
|
||||||
embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
|
embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
|
||||||
embed.GetProperty("color").GetString().Should().Be("#58B9FF");
|
embed.GetProperty("color").GetString().Should().Be("#58B9FF");
|
||||||
embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
|
embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
|
||||||
embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
|
embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
|
||||||
embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
|
embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
|
embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
|
||||||
embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);
|
embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);
|
||||||
embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
|
embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
|
||||||
embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
embedFields.Should().HaveCount(3);
|
embedFields.Should().HaveCount(3);
|
||||||
embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
|
embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
|
||||||
embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
|
embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
|
||||||
embedFields[0].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
embedFields[0].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
||||||
embedFields[1].GetProperty("name").GetString().Should().Be("Field 2");
|
embedFields[1].GetProperty("name").GetString().Should().Be("Field 2");
|
||||||
embedFields[1].GetProperty("value").GetString().Should().Be("Value 2");
|
embedFields[1].GetProperty("value").GetString().Should().Be("Value 2");
|
||||||
embedFields[1].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
embedFields[1].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
||||||
embedFields[2].GetProperty("name").GetString().Should().Be("Field 3");
|
embedFields[2].GetProperty("name").GetString().Should().Be("Field 3");
|
||||||
embedFields[2].GetProperty("value").GetString().Should().Be("Value 3");
|
embedFields[2].GetProperty("value").GetString().Should().Be("Value 3");
|
||||||
embedFields[2].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
embedFields[2].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,66 +6,65 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
|
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
[Fact]
|
||||||
|
public async Task User_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Act
|
||||||
public async Task User_mention_is_rendered_correctly()
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
{
|
ChannelIds.MentionTestCases,
|
||||||
// Act
|
Snowflake.Parse("866458840245076028")
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
);
|
||||||
ChannelIds.MentionTestCases,
|
|
||||||
Snowflake.Parse("866458840245076028")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("User mention: @Tyrrrz");
|
message.GetProperty("content").GetString().Should().Be("User mention: @Tyrrrz");
|
||||||
|
|
||||||
message
|
message
|
||||||
.GetProperty("mentions")
|
.GetProperty("mentions")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("id").GetString())
|
.Select(j => j.GetProperty("id").GetString())
|
||||||
.Should().Contain("128178626683338752");
|
.Should().Contain("128178626683338752");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Text_channel_mention_is_rendered_correctly()
|
public async Task Text_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459040480624680")
|
Snowflake.Parse("866459040480624680")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Text channel mention: #mention-tests");
|
message.GetProperty("content").GetString().Should().Be("Text channel mention: #mention-tests");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Voice_channel_mention_is_rendered_correctly()
|
public async Task Voice_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459175462633503")
|
Snowflake.Parse("866459175462633503")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Voice channel mention: #chaos-vc [voice]");
|
message.GetProperty("content").GetString().Should().Be("Voice channel mention: #chaos-vc [voice]");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Role_mention_is_rendered_correctly()
|
public async Task Role_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459254693429258")
|
Snowflake.Parse("866459254693429258")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,58 +10,57 @@ using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
[Fact]
|
||||||
|
public async Task Messages_partitioned_by_count_are_split_into_multiple_files_correctly()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Arrange
|
||||||
public async Task Messages_partitioned_by_count_are_split_into_multiple_files_correctly()
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
|
OutputPath = filePath,
|
||||||
|
PartitionLimit = PartitionLimit.Parse("3")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
// Assert
|
||||||
await new ExportChannelsCommand
|
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
||||||
{
|
.Should()
|
||||||
TokenValue = Secrets.DiscordToken,
|
.HaveCount(3);
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
}
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
|
||||||
OutputPath = filePath,
|
|
||||||
PartitionLimit = PartitionLimit.Parse("3")
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
// Assert
|
[Fact]
|
||||||
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
public async Task Messages_partitioned_by_file_size_are_split_into_multiple_files_correctly()
|
||||||
.Should()
|
{
|
||||||
.HaveCount(3);
|
// Arrange
|
||||||
}
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||||
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
[Fact]
|
// Act
|
||||||
public async Task Messages_partitioned_by_file_size_are_split_into_multiple_files_correctly()
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
|
OutputPath = filePath,
|
||||||
|
PartitionLimit = PartitionLimit.Parse("20kb")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
// Assert
|
||||||
await new ExportChannelsCommand
|
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
||||||
{
|
.Should()
|
||||||
TokenValue = Secrets.DiscordToken,
|
.HaveCount(2);
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
|
||||||
OutputPath = filePath,
|
|
||||||
PartitionLimit = PartitionLimit.Parse("20kb")
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
|
||||||
.Should()
|
|
||||||
.HaveCount(2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,28 +4,27 @@ using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.PlainTextWriting
|
namespace DiscordChatExporter.Cli.Tests.Specs.PlainTextWriting;
|
||||||
{
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Messages_are_exported_correctly()
|
|
||||||
{
|
|
||||||
// Act
|
|
||||||
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
|
||||||
|
|
||||||
// Assert
|
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
document.Should().ContainAll(
|
{
|
||||||
"Tyrrrz#5447",
|
[Fact]
|
||||||
"Hello world",
|
public async Task Messages_are_exported_correctly()
|
||||||
"Goodbye world",
|
{
|
||||||
"Foo bar",
|
// Act
|
||||||
"Hurdle Durdle",
|
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
||||||
"One",
|
|
||||||
"Two",
|
// Assert
|
||||||
"Three",
|
document.Should().ContainAll(
|
||||||
"Yeet"
|
"Tyrrrz#5447",
|
||||||
);
|
"Hello world",
|
||||||
}
|
"Goodbye world",
|
||||||
|
"Foo bar",
|
||||||
|
"Hurdle Durdle",
|
||||||
|
"One",
|
||||||
|
"Two",
|
||||||
|
"Three",
|
||||||
|
"Yeet"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -11,39 +11,38 @@ using DiscordChatExporter.Core.Exporting;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
[Fact]
|
||||||
|
public async Task Messages_in_self_contained_export_only_reference_local_file_resources()
|
||||||
{
|
{
|
||||||
[Fact]
|
// Arrange
|
||||||
public async Task Messages_in_self_contained_export_only_reference_local_file_resources()
|
var filePath = TempOutput.GetTempFilePath();
|
||||||
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
// Arrange
|
TokenValue = Secrets.DiscordToken,
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
||||||
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
|
OutputPath = filePath,
|
||||||
|
ShouldDownloadMedia = true
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Act
|
var data = await File.ReadAllTextAsync(filePath);
|
||||||
await new ExportChannelsCommand
|
var document = Html.Parse(data);
|
||||||
{
|
|
||||||
TokenValue = Secrets.DiscordToken,
|
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
|
||||||
OutputPath = filePath,
|
|
||||||
ShouldDownloadMedia = true
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Html.Parse(data);
|
document
|
||||||
|
.QuerySelectorAll("body [src]")
|
||||||
// Assert
|
.Select(e => e.GetAttribute("src")!)
|
||||||
document
|
.Select(f => Path.GetFullPath(f, dirPath))
|
||||||
.QuerySelectorAll("body [src]")
|
.All(File.Exists)
|
||||||
.Select(e => e.GetAttribute("src")!)
|
.Should()
|
||||||
.Select(f => Path.GetFullPath(f, dirPath))
|
.BeTrue();
|
||||||
.All(File.Exists)
|
|
||||||
.Should()
|
|
||||||
.BeTrue();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.TestData
|
namespace DiscordChatExporter.Cli.Tests.TestData;
|
||||||
|
|
||||||
|
public static class ChannelIds
|
||||||
{
|
{
|
||||||
public static class ChannelIds
|
public static Snowflake AttachmentTestCases { get; } = Snowflake.Parse("885587741654536192");
|
||||||
{
|
|
||||||
public static Snowflake AttachmentTestCases { get; } = Snowflake.Parse("885587741654536192");
|
|
||||||
|
|
||||||
public static Snowflake DateRangeTestCases { get; } = Snowflake.Parse("866674248747319326");
|
public static Snowflake DateRangeTestCases { get; } = Snowflake.Parse("866674248747319326");
|
||||||
|
|
||||||
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
||||||
|
|
||||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||||
|
|
||||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||||
|
|
||||||
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");
|
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");
|
||||||
|
|
||||||
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
|
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
{
|
|
||||||
internal static class DirectoryEx
|
|
||||||
{
|
|
||||||
public static void DeleteIfExists(string dirPath, bool recursive = true)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Directory.Delete(dirPath, recursive);
|
|
||||||
}
|
|
||||||
catch (DirectoryNotFoundException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void Reset(string dirPath)
|
internal static class DirectoryEx
|
||||||
|
{
|
||||||
|
public static void DeleteIfExists(string dirPath, bool recursive = true)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(dirPath, recursive);
|
||||||
|
}
|
||||||
|
catch (DirectoryNotFoundException)
|
||||||
{
|
{
|
||||||
DeleteIfExists(dirPath);
|
|
||||||
Directory.CreateDirectory(dirPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void Reset(string dirPath)
|
||||||
|
{
|
||||||
|
DeleteIfExists(dirPath);
|
||||||
|
Directory.CreateDirectory(dirPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
using AngleSharp.Html.Dom;
|
using AngleSharp.Html.Dom;
|
||||||
using AngleSharp.Html.Parser;
|
using AngleSharp.Html.Parser;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
{
|
|
||||||
internal static class Html
|
|
||||||
{
|
|
||||||
private static readonly IHtmlParser Parser = new HtmlParser();
|
|
||||||
|
|
||||||
public static IHtmlDocument Parse(string source) => Parser.ParseDocument(source);
|
internal static class Html
|
||||||
}
|
{
|
||||||
|
private static readonly IHtmlParser Parser = new HtmlParser();
|
||||||
|
|
||||||
|
public static IHtmlDocument Parse(string source) => Parser.ParseDocument(source);
|
||||||
}
|
}
|
||||||
|
|
@ -16,141 +16,140 @@ using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands.Base
|
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
|
||||||
|
public abstract class ExportCommandBase : TokenCommandBase
|
||||||
{
|
{
|
||||||
public abstract class ExportCommandBase : TokenCommandBase
|
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
||||||
|
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
[CommandOption("format", 'f', Description = "Export format.")]
|
||||||
|
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
|
[CommandOption("after", Description = "Only include messages sent after this date or message ID.")]
|
||||||
|
public Snowflake? After { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
|
||||||
|
public Snowflake? Before { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("partition", 'p', Description = "Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb').")]
|
||||||
|
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||||
|
|
||||||
|
[CommandOption("filter", Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image').")]
|
||||||
|
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
||||||
|
|
||||||
|
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
|
||||||
|
public int ParallelLimit { get; init; } = 1;
|
||||||
|
|
||||||
|
[CommandOption("media", Description = "Download referenced media content.")]
|
||||||
|
public bool ShouldDownloadMedia { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("reuse-media", Description = "Reuse already existing media content to skip redundant downloads.")]
|
||||||
|
public bool ShouldReuseMedia { get; init; }
|
||||||
|
|
||||||
|
[CommandOption("dateformat", Description = "Format used when writing dates.")]
|
||||||
|
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
|
||||||
|
private ChannelExporter? _channelExporter;
|
||||||
|
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
||||||
|
|
||||||
|
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
|
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Export format.")]
|
if (ShouldReuseMedia && !ShouldDownloadMedia)
|
||||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
|
||||||
|
|
||||||
[CommandOption("after", Description = "Only include messages sent after this date or message ID.")]
|
|
||||||
public Snowflake? After { get; init; }
|
|
||||||
|
|
||||||
[CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
|
|
||||||
public Snowflake? Before { get; init; }
|
|
||||||
|
|
||||||
[CommandOption("partition", 'p', Description = "Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb').")]
|
|
||||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
|
||||||
|
|
||||||
[CommandOption("filter", Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image').")]
|
|
||||||
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
|
||||||
|
|
||||||
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
|
|
||||||
public int ParallelLimit { get; init; } = 1;
|
|
||||||
|
|
||||||
[CommandOption("media", Description = "Download referenced media content.")]
|
|
||||||
public bool ShouldDownloadMedia { get; init; }
|
|
||||||
|
|
||||||
[CommandOption("reuse-media", Description = "Reuse already existing media content to skip redundant downloads.")]
|
|
||||||
public bool ShouldReuseMedia { get; init; }
|
|
||||||
|
|
||||||
[CommandOption("dateformat", Description = "Format used when writing dates.")]
|
|
||||||
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
|
||||||
|
|
||||||
private ChannelExporter? _channelExporter;
|
|
||||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
|
||||||
|
|
||||||
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
|
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
throw new CommandException("Option --reuse-media cannot be used without --media.");
|
||||||
|
}
|
||||||
|
|
||||||
if (ShouldReuseMedia && !ShouldDownloadMedia)
|
var errors = new ConcurrentDictionary<Channel, string>();
|
||||||
|
|
||||||
|
// Export
|
||||||
|
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
||||||
|
await console.CreateProgressTicker().StartAsync(async progressContext =>
|
||||||
|
{
|
||||||
|
await channels.ParallelForEachAsync(async channel =>
|
||||||
{
|
{
|
||||||
throw new CommandException("Option --reuse-media cannot be used without --media.");
|
try
|
||||||
}
|
|
||||||
|
|
||||||
var errors = new ConcurrentDictionary<Channel, string>();
|
|
||||||
|
|
||||||
// Export
|
|
||||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
|
||||||
await console.CreateProgressTicker().StartAsync(async progressContext =>
|
|
||||||
{
|
|
||||||
await channels.ParallelForEachAsync(async channel =>
|
|
||||||
{
|
{
|
||||||
try
|
await progressContext.StartTaskAsync($"{channel.Category.Name} / {channel.Name}", async progress =>
|
||||||
{
|
{
|
||||||
await progressContext.StartTaskAsync($"{channel.Category.Name} / {channel.Name}", async progress =>
|
var guild = await Discord.GetGuildAsync(channel.GuildId, cancellationToken);
|
||||||
{
|
|
||||||
var guild = await Discord.GetGuildAsync(channel.GuildId, cancellationToken);
|
|
||||||
|
|
||||||
var request = new ExportRequest(
|
var request = new ExportRequest(
|
||||||
guild,
|
guild,
|
||||||
channel,
|
channel,
|
||||||
OutputPath,
|
OutputPath,
|
||||||
ExportFormat,
|
ExportFormat,
|
||||||
After,
|
After,
|
||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
MessageFilter,
|
MessageFilter,
|
||||||
ShouldDownloadMedia,
|
ShouldDownloadMedia,
|
||||||
ShouldReuseMedia,
|
ShouldReuseMedia,
|
||||||
DateFormat
|
DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
await Exporter.ExportChannelAsync(request, progress, cancellationToken);
|
await Exporter.ExportChannelAsync(request, progress, cancellationToken);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
{
|
{
|
||||||
errors[channel] = ex.Message;
|
errors[channel] = ex.Message;
|
||||||
}
|
}
|
||||||
}, Math.Max(ParallelLimit, 1), cancellationToken);
|
}, Math.Max(ParallelLimit, 1), cancellationToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Print result
|
// Print result
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync(
|
||||||
|
$"Successfully exported {channels.Count - errors.Count} channel(s)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print errors
|
||||||
|
if (errors.Any())
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
{
|
{
|
||||||
await console.Output.WriteLineAsync(
|
await console.Output.WriteLineAsync(
|
||||||
$"Successfully exported {channels.Count - errors.Count} channel(s)."
|
$"Failed to export {errors.Count} channel(s):"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print errors
|
foreach (var (channel, error) in errors)
|
||||||
if (errors.Any())
|
|
||||||
{
|
{
|
||||||
await console.Output.WriteLineAsync();
|
await console.Output.WriteAsync($"{channel.Category.Name} / {channel.Name}: ");
|
||||||
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
{
|
await console.Output.WriteLineAsync(error);
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Failed to export {errors.Count} channel(s):"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var (channel, error) in errors)
|
|
||||||
{
|
|
||||||
await console.Output.WriteAsync($"{channel.Category.Name} / {channel.Name}: ");
|
|
||||||
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
|
||||||
await console.Output.WriteLineAsync(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fail the command only if ALL channels failed to export.
|
await console.Output.WriteLineAsync();
|
||||||
// Having some of the channels fail to export is expected.
|
|
||||||
if (errors.Count >= channels.Count)
|
|
||||||
{
|
|
||||||
throw new CommandException("Export failed.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
|
// Fail the command only if ALL channels failed to export.
|
||||||
|
// Having some of the channels fail to export is expected.
|
||||||
|
if (errors.Count >= channels.Count)
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
throw new CommandException("Export failed.");
|
||||||
var channels = new List<Channel>();
|
|
||||||
|
|
||||||
foreach (var channelId in channelIds)
|
|
||||||
{
|
|
||||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
|
||||||
channels.Add(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExecuteAsync(console, channels);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
|
||||||
|
{
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
|
foreach (var channelId in channelIds)
|
||||||
|
{
|
||||||
|
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||||
|
channels.Add(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExecuteAsync(console, channels);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,27 +4,26 @@ using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands.Base
|
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
|
||||||
|
public abstract class TokenCommandBase : ICommand
|
||||||
{
|
{
|
||||||
public abstract class TokenCommandBase : ICommand
|
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
|
||||||
{
|
public string TokenValue { get; init; } = "";
|
||||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
|
|
||||||
public string TokenValue { get; init; } = "";
|
|
||||||
|
|
||||||
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")]
|
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")]
|
||||||
public bool IsBotToken { get; init; }
|
public bool IsBotToken { get; init; }
|
||||||
|
|
||||||
private AuthToken? _authToken;
|
private AuthToken? _authToken;
|
||||||
private AuthToken AuthToken => _authToken ??= new AuthToken(
|
private AuthToken AuthToken => _authToken ??= new AuthToken(
|
||||||
IsBotToken
|
IsBotToken
|
||||||
? AuthTokenKind.Bot
|
? AuthTokenKind.Bot
|
||||||
: AuthTokenKind.User,
|
: AuthTokenKind.User,
|
||||||
TokenValue
|
TokenValue
|
||||||
);
|
);
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
private DiscordClient? _discordClient;
|
||||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken);
|
protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken);
|
||||||
|
|
||||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,37 +5,36 @@ using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("exportall", Description = "Export all accessible channels.")]
|
||||||
|
public class ExportAllCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[Command("exportall", Description = "Export all accessible channels.")]
|
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
||||||
public class ExportAllCommand : ExportCommandBase
|
public bool IncludeDirectMessages { get; init; } = true;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
public bool IncludeDirectMessages { get; init; } = true;
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
|
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
// Skip DMs if instructed to
|
||||||
var channels = new List<Channel>();
|
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
|
||||||
|
continue;
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
|
||||||
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
|
|
||||||
{
|
{
|
||||||
// Skip DMs if instructed to
|
// Skip non-text channels
|
||||||
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
|
if (!channel.IsTextChannel)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
|
channels.Add(channel);
|
||||||
{
|
|
||||||
// Skip non-text channels
|
|
||||||
if (!channel.IsTextChannel)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
channels.Add(channel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await base.ExecuteAsync(console, channels);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await base.ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,16 +6,15 @@ using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
{
|
|
||||||
[Command("export", Description = "Export one or multiple channels.")]
|
|
||||||
public class ExportChannelsCommand : ExportCommandBase
|
|
||||||
{
|
|
||||||
// TODO: change this to plural (breaking change)
|
|
||||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
|
|
||||||
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console) =>
|
[Command("export", Description = "Export one or multiple channels.")]
|
||||||
await base.ExecuteAsync(console, ChannelIds);
|
public class ExportChannelsCommand : ExportCommandBase
|
||||||
}
|
{
|
||||||
|
// TODO: change this to plural (breaking change)
|
||||||
|
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
|
||||||
|
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console) =>
|
||||||
|
await base.ExecuteAsync(console, ChannelIds);
|
||||||
}
|
}
|
||||||
|
|
@ -6,20 +6,19 @@ using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("exportdm", Description = "Export all direct message channels.")]
|
||||||
|
public class ExportDirectMessagesCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[Command("exportdm", Description = "Export all direct message channels.")]
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
public class ExportDirectMessagesCommand : ExportCommandBase
|
|
||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
{
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
||||||
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
||||||
|
|
||||||
await base.ExecuteAsync(console, textChannels);
|
await base.ExecuteAsync(console, textChannels);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,23 +6,22 @@ using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
||||||
|
public class ExportGuildCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||||
public class ExportGuildCommand : ExportCommandBase
|
public Snowflake GuildId { get; init; }
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
public Snowflake GuildId { get; init; }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
{
|
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await base.ExecuteAsync(console, textChannels);
|
||||||
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
|
||||||
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
|
||||||
|
|
||||||
await base.ExecuteAsync(console, textChannels);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,39 +7,38 @@ using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||||
|
public class GetChannelsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
[Command("channels", Description = "Get the list of channels in a guild.")]
|
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||||
public class GetChannelsCommand : TokenCommandBase
|
public Snowflake GuildId { get; init; }
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
public Snowflake GuildId { get; init; }
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
||||||
|
|
||||||
|
var textChannels = channels
|
||||||
|
.Where(c => c.IsTextChannel)
|
||||||
|
.OrderBy(c => c.Category.Position)
|
||||||
|
.ThenBy(c => c.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var channel in textChannels)
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
// Channel ID
|
||||||
|
await console.Output.WriteAsync(channel.Id.ToString());
|
||||||
|
|
||||||
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
// Separator
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
await console.Output.WriteAsync(" | ");
|
||||||
|
|
||||||
var textChannels = channels
|
// Channel category / name
|
||||||
.Where(c => c.IsTextChannel)
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
.OrderBy(c => c.Category.Position)
|
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}");
|
||||||
.ThenBy(c => c.Name)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var channel in textChannels)
|
|
||||||
{
|
|
||||||
// Channel ID
|
|
||||||
await console.Output.WriteAsync(channel.Id.ToString());
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
|
||||||
await console.Output.WriteAsync(" | ");
|
|
||||||
|
|
||||||
// Channel category / name
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
|
||||||
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,36 +7,35 @@ using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("dm", Description = "Get the list of direct message channels.")]
|
||||||
|
public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
[Command("dm", Description = "Get the list of direct message channels.")]
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
public class GetDirectMessageChannelsCommand : TokenCommandBase
|
|
||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
||||||
|
|
||||||
|
var textChannels = channels
|
||||||
|
.Where(c => c.IsTextChannel)
|
||||||
|
.OrderBy(c => c.Category.Position)
|
||||||
|
.ThenBy(c => c.Name)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
foreach (var channel in textChannels)
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
// Channel ID
|
||||||
|
await console.Output.WriteAsync(channel.Id.ToString());
|
||||||
|
|
||||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
// Separator
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
await console.Output.WriteAsync(" | ");
|
||||||
|
|
||||||
var textChannels = channels
|
// Channel category / name
|
||||||
.Where(c => c.IsTextChannel)
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
.OrderBy(c => c.Category.Position)
|
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}");
|
||||||
.ThenBy(c => c.Name)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var channel in textChannels)
|
|
||||||
{
|
|
||||||
// Channel ID
|
|
||||||
await console.Output.WriteAsync(channel.Id.ToString());
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
|
||||||
await console.Output.WriteAsync(" | ");
|
|
||||||
|
|
||||||
// Channel category / name
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
|
||||||
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,30 +6,29 @@ using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("guilds", Description = "Get the list of accessible guilds.")]
|
||||||
|
public class GetGuildsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
[Command("guilds", Description = "Get the list of accessible guilds.")]
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
public class GetGuildsCommand : TokenCommandBase
|
|
||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
var guilds = await Discord.GetUserGuildsAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var guild in guilds.OrderBy(g => g.Name))
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
// Guild ID
|
||||||
|
await console.Output.WriteAsync(guild.Id.ToString());
|
||||||
|
|
||||||
var guilds = await Discord.GetUserGuildsAsync(cancellationToken);
|
// Separator
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
await console.Output.WriteAsync(" | ");
|
||||||
|
|
||||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
// Guild name
|
||||||
{
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
// Guild ID
|
await console.Output.WriteLineAsync(guild.Name);
|
||||||
await console.Output.WriteAsync(guild.Id.ToString());
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
|
||||||
await console.Output.WriteAsync(" | ");
|
|
||||||
|
|
||||||
// Guild name
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
|
||||||
await console.Output.WriteLineAsync(guild.Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,68 +4,67 @@ using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
|
[Command("guide", Description = "Explains how to obtain token, guild or channel ID.")]
|
||||||
|
public class GuideCommand : ICommand
|
||||||
{
|
{
|
||||||
[Command("guide", Description = "Explains how to obtain token, guild or channel ID.")]
|
public ValueTask ExecuteAsync(IConsole console)
|
||||||
public class GuideCommand : ICommand
|
|
||||||
{
|
{
|
||||||
public ValueTask ExecuteAsync(IConsole console)
|
// User token
|
||||||
{
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
// User token
|
console.Output.WriteLine("To get user token:");
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
|
||||||
console.Output.WriteLine("To get user token:");
|
|
||||||
|
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
|
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
|
||||||
console.Output.WriteLine(" 4. Navigate to the Application tab");
|
console.Output.WriteLine(" 4. Navigate to the Application tab");
|
||||||
console.Output.WriteLine(" 5. On the left, expand Local Storage and select https://discord.com");
|
console.Output.WriteLine(" 5. On the left, expand Local Storage and select https://discord.com");
|
||||||
console.Output.WriteLine(" 6. Type \"token\" into the Filter box");
|
console.Output.WriteLine(" 6. Type \"token\" into the Filter box");
|
||||||
console.Output.WriteLine(" 7. If the token key does not appear, press Ctrl+R to reload");
|
console.Output.WriteLine(" 7. If the token key does not appear, press Ctrl+R to reload");
|
||||||
console.Output.WriteLine(" 8. Copy the value of the token key");
|
console.Output.WriteLine(" 8. Copy the value of the token key");
|
||||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
// Bot token
|
// Bot token
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get bot token:");
|
console.Output.WriteLine("To get bot token:");
|
||||||
|
|
||||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||||
console.Output.WriteLine(" 2. Open your application's settings");
|
console.Output.WriteLine(" 2. Open your application's settings");
|
||||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
// Guild or channel ID
|
// Guild or channel ID
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get guild ID or guild channel ID:");
|
console.Output.WriteLine("To get guild ID or guild channel ID:");
|
||||||
|
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Open Settings");
|
console.Output.WriteLine(" 2. Open Settings");
|
||||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
console.Output.WriteLine(" 3. Go to Appearance section");
|
||||||
console.Output.WriteLine(" 4. Enable Developer Mode");
|
console.Output.WriteLine(" 4. Enable Developer Mode");
|
||||||
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
// Direct message channel ID
|
// Direct message channel ID
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get direct message channel ID:");
|
console.Output.WriteLine("To get direct message channel ID:");
|
||||||
|
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Open the desired direct message channel");
|
console.Output.WriteLine(" 2. Open the desired direct message channel");
|
||||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||||
console.Output.WriteLine(" 4. Navigate to the Console tab");
|
console.Output.WriteLine(" 4. Navigate to the Console tab");
|
||||||
console.Output.WriteLine(" 5. Type \"window.location.href\" and press Enter");
|
console.Output.WriteLine(" 5. Type \"window.location.href\" and press Enter");
|
||||||
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
// Wiki link
|
// Wiki link
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("For more information, check out the wiki:");
|
console.Output.WriteLine("For more information, check out the wiki:");
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
||||||
|
|
||||||
return default;
|
return default;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
using System.Threading.Tasks;
|
using CliFx;
|
||||||
using CliFx;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli
|
return await new CliApplicationBuilder()
|
||||||
{
|
.AddCommandsFromThisAssembly()
|
||||||
public static class Program
|
.Build()
|
||||||
{
|
.RunAsync(args);
|
||||||
public static async Task<int> Main(string[] args) =>
|
|
||||||
await new CliApplicationBuilder()
|
|
||||||
.AddCommandsFromThisAssembly()
|
|
||||||
.Build()
|
|
||||||
.RunAsync(args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,49 +3,48 @@ using System.Threading.Tasks;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Utils.Extensions
|
namespace DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class ConsoleExtensions
|
||||||
{
|
{
|
||||||
internal static class ConsoleExtensions
|
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
||||||
{
|
AnsiConsole.Create(new AnsiConsoleSettings
|
||||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
|
||||||
AnsiConsole.Create(new AnsiConsoleSettings
|
|
||||||
{
|
|
||||||
Ansi = AnsiSupport.Detect,
|
|
||||||
ColorSystem = ColorSystemSupport.Detect,
|
|
||||||
Out = new AnsiConsoleOutput(console.Output)
|
|
||||||
});
|
|
||||||
|
|
||||||
public static Progress CreateProgressTicker(this IConsole console) => console
|
|
||||||
.CreateAnsiConsole()
|
|
||||||
.Progress()
|
|
||||||
.AutoClear(false)
|
|
||||||
.AutoRefresh(true)
|
|
||||||
.HideCompleted(false)
|
|
||||||
.Columns(
|
|
||||||
new TaskDescriptionColumn {Alignment = Justify.Left},
|
|
||||||
new ProgressBarColumn(),
|
|
||||||
new PercentageColumn()
|
|
||||||
);
|
|
||||||
|
|
||||||
public static async ValueTask StartTaskAsync(
|
|
||||||
this ProgressContext progressContext,
|
|
||||||
string description,
|
|
||||||
Func<ProgressTask, ValueTask> performOperationAsync)
|
|
||||||
{
|
{
|
||||||
var progressTask = progressContext.AddTask(
|
Ansi = AnsiSupport.Detect,
|
||||||
// Don't recognize random square brackets as style tags
|
ColorSystem = ColorSystemSupport.Detect,
|
||||||
Markup.Escape(description),
|
Out = new AnsiConsoleOutput(console.Output)
|
||||||
new ProgressTaskSettings {MaxValue = 1}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
try
|
public static Progress CreateProgressTicker(this IConsole console) => console
|
||||||
{
|
.CreateAnsiConsole()
|
||||||
await performOperationAsync(progressTask);
|
.Progress()
|
||||||
}
|
.AutoClear(false)
|
||||||
finally
|
.AutoRefresh(true)
|
||||||
{
|
.HideCompleted(false)
|
||||||
progressTask.StopTask();
|
.Columns(
|
||||||
}
|
new TaskDescriptionColumn {Alignment = Justify.Left},
|
||||||
|
new ProgressBarColumn(),
|
||||||
|
new PercentageColumn()
|
||||||
|
);
|
||||||
|
|
||||||
|
public static async ValueTask StartTaskAsync(
|
||||||
|
this ProgressContext progressContext,
|
||||||
|
string description,
|
||||||
|
Func<ProgressTask, ValueTask> performOperationAsync)
|
||||||
|
{
|
||||||
|
var progressTask = progressContext.AddTask(
|
||||||
|
// Don't recognize random square brackets as style tags
|
||||||
|
Markup.Escape(description),
|
||||||
|
new ProgressTaskSettings {MaxValue = 1}
|
||||||
|
);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await performOperationAsync(progressTask);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
progressTask.StopTask();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
public record AuthToken(AuthTokenKind Kind, string Value)
|
||||||
{
|
{
|
||||||
public record AuthToken(AuthTokenKind Kind, string Value)
|
public AuthenticationHeaderValue GetAuthenticationHeader() => Kind switch
|
||||||
{
|
{
|
||||||
public AuthenticationHeaderValue GetAuthenticationHeader() => Kind switch
|
AuthTokenKind.Bot => new AuthenticationHeaderValue("Bot", Value),
|
||||||
{
|
_ => new AuthenticationHeaderValue(Value)
|
||||||
AuthTokenKind.Bot => new AuthenticationHeaderValue("Bot", Value),
|
};
|
||||||
_ => new AuthenticationHeaderValue(Value)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
namespace DiscordChatExporter.Core.Discord
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
public enum AuthTokenKind
|
||||||
{
|
{
|
||||||
public enum AuthTokenKind
|
User,
|
||||||
{
|
Bot
|
||||||
User,
|
|
||||||
Bot
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -6,40 +6,39 @@ using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#attachment-object
|
||||||
|
public partial record Attachment(
|
||||||
|
Snowflake Id,
|
||||||
|
string Url,
|
||||||
|
string FileName,
|
||||||
|
int? Width,
|
||||||
|
int? Height,
|
||||||
|
FileSize FileSize) : IHasId
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/channel#attachment-object
|
public string FileExtension => Path.GetExtension(FileName);
|
||||||
public partial record Attachment(
|
|
||||||
Snowflake Id,
|
public bool IsImage => FileFormat.IsImage(FileExtension);
|
||||||
string Url,
|
|
||||||
string FileName,
|
public bool IsVideo => FileFormat.IsVideo(FileExtension);
|
||||||
int? Width,
|
|
||||||
int? Height,
|
public bool IsAudio => FileFormat.IsAudio(FileExtension);
|
||||||
FileSize FileSize) : IHasId
|
|
||||||
|
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record Attachment
|
||||||
|
{
|
||||||
|
public static Attachment Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
public string FileExtension => Path.GetExtension(FileName);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
|
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
||||||
|
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
||||||
|
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
||||||
|
var fileName = json.GetProperty("filename").GetNonWhiteSpaceString();
|
||||||
|
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
||||||
|
|
||||||
public bool IsImage => FileFormat.IsImage(FileExtension);
|
return new Attachment(id, url, fileName, width, height, fileSize);
|
||||||
|
|
||||||
public bool IsVideo => FileFormat.IsVideo(FileExtension);
|
|
||||||
|
|
||||||
public bool IsAudio => FileFormat.IsAudio(FileExtension);
|
|
||||||
|
|
||||||
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial record Attachment
|
|
||||||
{
|
|
||||||
public static Attachment Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
|
||||||
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
|
||||||
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
|
||||||
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
|
||||||
var fileName = json.GetProperty("filename").GetNonWhiteSpaceString();
|
|
||||||
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
|
||||||
|
|
||||||
return new Attachment(id, url, fileName, width, height, fileSize);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,69 +4,68 @@ using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#channel-object
|
||||||
|
public partial record Channel(
|
||||||
|
Snowflake Id,
|
||||||
|
ChannelKind Kind,
|
||||||
|
Snowflake GuildId,
|
||||||
|
ChannelCategory Category,
|
||||||
|
string Name,
|
||||||
|
int? Position,
|
||||||
|
string? Topic) : IHasId
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object
|
public bool IsTextChannel => Kind is
|
||||||
public partial record Channel(
|
ChannelKind.GuildTextChat or
|
||||||
Snowflake Id,
|
ChannelKind.DirectTextChat or
|
||||||
ChannelKind Kind,
|
ChannelKind.DirectGroupTextChat or
|
||||||
Snowflake GuildId,
|
ChannelKind.GuildNews or
|
||||||
ChannelCategory Category,
|
ChannelKind.GuildStore;
|
||||||
string Name,
|
|
||||||
int? Position,
|
|
||||||
string? Topic) : IHasId
|
|
||||||
{
|
|
||||||
public bool IsTextChannel => Kind is
|
|
||||||
ChannelKind.GuildTextChat or
|
|
||||||
ChannelKind.DirectTextChat or
|
|
||||||
ChannelKind.DirectGroupTextChat or
|
|
||||||
ChannelKind.GuildNews or
|
|
||||||
ChannelKind.GuildStore;
|
|
||||||
|
|
||||||
public bool IsVoiceChannel => !IsTextChannel;
|
public bool IsVoiceChannel => !IsTextChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Channel
|
public partial record Channel
|
||||||
{
|
{
|
||||||
private static ChannelCategory GetFallbackCategory(ChannelKind channelKind) => new(
|
private static ChannelCategory GetFallbackCategory(ChannelKind channelKind) => new(
|
||||||
Snowflake.Zero,
|
Snowflake.Zero,
|
||||||
channelKind switch
|
channelKind switch
|
||||||
{
|
|
||||||
ChannelKind.GuildTextChat => "Text",
|
|
||||||
ChannelKind.DirectTextChat => "Private",
|
|
||||||
ChannelKind.DirectGroupTextChat => "Group",
|
|
||||||
ChannelKind.GuildNews => "News",
|
|
||||||
ChannelKind.GuildStore => "Store",
|
|
||||||
_ => "Default"
|
|
||||||
},
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? position = null)
|
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
ChannelKind.GuildTextChat => "Text",
|
||||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
ChannelKind.DirectTextChat => "Private",
|
||||||
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
ChannelKind.DirectGroupTextChat => "Group",
|
||||||
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
ChannelKind.GuildNews => "News",
|
||||||
|
ChannelKind.GuildStore => "Store",
|
||||||
|
_ => "Default"
|
||||||
|
},
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
var name =
|
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? position = null)
|
||||||
// Guild channel
|
{
|
||||||
json.GetPropertyOrNull("name")?.GetStringOrNull() ??
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
// DM channel
|
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name)
|
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
||||||
.Pipe(s => string.Join(", ", s)) ??
|
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
||||||
// Fallback
|
|
||||||
id.ToString();
|
|
||||||
|
|
||||||
return new Channel(
|
var name =
|
||||||
id,
|
// Guild channel
|
||||||
kind,
|
json.GetPropertyOrNull("name")?.GetStringOrNull() ??
|
||||||
guildId ?? Guild.DirectMessages.Id,
|
// DM channel
|
||||||
category ?? GetFallbackCategory(kind),
|
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name)
|
||||||
name,
|
.Pipe(s => string.Join(", ", s)) ??
|
||||||
position ?? json.GetPropertyOrNull("position")?.GetInt32(),
|
// Fallback
|
||||||
topic
|
id.ToString();
|
||||||
);
|
|
||||||
}
|
return new Channel(
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
guildId ?? Guild.DirectMessages.Id,
|
||||||
|
category ?? GetFallbackCategory(kind),
|
||||||
|
name,
|
||||||
|
position ?? json.GetPropertyOrNull("position")?.GetInt32(),
|
||||||
|
topic
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,25 +3,24 @@ using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
|
||||||
{
|
{
|
||||||
public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
|
public static ChannelCategory Unknown { get; } = new(Snowflake.Zero, "<unknown category>", 0);
|
||||||
|
|
||||||
|
public static ChannelCategory Parse(JsonElement json, int? position = null)
|
||||||
{
|
{
|
||||||
public static ChannelCategory Unknown { get; } = new(Snowflake.Zero, "<unknown category>", 0);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
public static ChannelCategory Parse(JsonElement json, int? position = null)
|
var name =
|
||||||
{
|
json.GetPropertyOrNull("name")?.GetStringOrNull() ??
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
id.ToString();
|
||||||
|
|
||||||
var name =
|
return new ChannelCategory(
|
||||||
json.GetPropertyOrNull("name")?.GetStringOrNull() ??
|
id,
|
||||||
id.ToString();
|
name,
|
||||||
|
position ?? json.GetPropertyOrNull("position")?.GetInt32()
|
||||||
return new ChannelCategory(
|
);
|
||||||
id,
|
|
||||||
name,
|
|
||||||
position ?? json.GetPropertyOrNull("position")?.GetInt32()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
|
// Order of enum fields needs to match the order in the docs.
|
||||||
|
public enum ChannelKind
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
GuildTextChat = 0,
|
||||||
// Order of enum fields needs to match the order in the docs.
|
DirectTextChat,
|
||||||
public enum ChannelKind
|
GuildVoiceChat,
|
||||||
{
|
DirectGroupTextChat,
|
||||||
GuildTextChat = 0,
|
GuildCategory,
|
||||||
DirectTextChat,
|
GuildNews,
|
||||||
GuildVoiceChat,
|
GuildStore
|
||||||
DirectGroupTextChat,
|
|
||||||
GuildCategory,
|
|
||||||
GuildNews,
|
|
||||||
GuildStore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,49 +1,48 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
|
|
||||||
|
// Loosely based on https://github.com/omar/ByteSize (MIT license)
|
||||||
|
public readonly partial record struct FileSize(long TotalBytes)
|
||||||
{
|
{
|
||||||
// Loosely based on https://github.com/omar/ByteSize (MIT license)
|
public double TotalKiloBytes => TotalBytes / 1024.0;
|
||||||
public readonly partial record struct FileSize(long TotalBytes)
|
public double TotalMegaBytes => TotalKiloBytes / 1024.0;
|
||||||
|
public double TotalGigaBytes => TotalMegaBytes / 1024.0;
|
||||||
|
|
||||||
|
private double GetLargestWholeNumberValue()
|
||||||
{
|
{
|
||||||
public double TotalKiloBytes => TotalBytes / 1024.0;
|
if (Math.Abs(TotalGigaBytes) >= 1)
|
||||||
public double TotalMegaBytes => TotalKiloBytes / 1024.0;
|
return TotalGigaBytes;
|
||||||
public double TotalGigaBytes => TotalMegaBytes / 1024.0;
|
|
||||||
|
|
||||||
private double GetLargestWholeNumberValue()
|
if (Math.Abs(TotalMegaBytes) >= 1)
|
||||||
{
|
return TotalMegaBytes;
|
||||||
if (Math.Abs(TotalGigaBytes) >= 1)
|
|
||||||
return TotalGigaBytes;
|
|
||||||
|
|
||||||
if (Math.Abs(TotalMegaBytes) >= 1)
|
if (Math.Abs(TotalKiloBytes) >= 1)
|
||||||
return TotalMegaBytes;
|
return TotalKiloBytes;
|
||||||
|
|
||||||
if (Math.Abs(TotalKiloBytes) >= 1)
|
return TotalBytes;
|
||||||
return TotalKiloBytes;
|
|
||||||
|
|
||||||
return TotalBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetLargestWholeNumberSymbol()
|
|
||||||
{
|
|
||||||
if (Math.Abs(TotalGigaBytes) >= 1)
|
|
||||||
return "GB";
|
|
||||||
|
|
||||||
if (Math.Abs(TotalMegaBytes) >= 1)
|
|
||||||
return "MB";
|
|
||||||
|
|
||||||
if (Math.Abs(TotalKiloBytes) >= 1)
|
|
||||||
return "KB";
|
|
||||||
|
|
||||||
return "bytes";
|
|
||||||
}
|
|
||||||
|
|
||||||
[ExcludeFromCodeCoverage]
|
|
||||||
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record struct FileSize
|
private string GetLargestWholeNumberSymbol()
|
||||||
{
|
{
|
||||||
public static FileSize FromBytes(long bytes) => new(bytes);
|
if (Math.Abs(TotalGigaBytes) >= 1)
|
||||||
|
return "GB";
|
||||||
|
|
||||||
|
if (Math.Abs(TotalMegaBytes) >= 1)
|
||||||
|
return "MB";
|
||||||
|
|
||||||
|
if (Math.Abs(TotalKiloBytes) >= 1)
|
||||||
|
return "KB";
|
||||||
|
|
||||||
|
return "bytes";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record struct FileSize
|
||||||
|
{
|
||||||
|
public static FileSize FromBytes(long bytes) => new(bytes);
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
|
|
||||||
|
public interface IHasId
|
||||||
{
|
{
|
||||||
public interface IHasId
|
Snowflake Id { get; }
|
||||||
{
|
|
||||||
Snowflake Id { get; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
|
|
||||||
|
public class IdBasedEqualityComparer : IEqualityComparer<IHasId>
|
||||||
{
|
{
|
||||||
public class IdBasedEqualityComparer : IEqualityComparer<IHasId>
|
public static IdBasedEqualityComparer Instance { get; } = new();
|
||||||
{
|
|
||||||
public static IdBasedEqualityComparer Instance { get; } = new();
|
|
||||||
|
|
||||||
public bool Equals(IHasId? x, IHasId? y) => x?.Id == y?.Id;
|
public bool Equals(IHasId? x, IHasId? y) => x?.Id == y?.Id;
|
||||||
|
|
||||||
public int GetHashCode(IHasId obj) => obj.Id.GetHashCode();
|
public int GetHashCode(IHasId obj) => obj.Id.GetHashCode();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -6,62 +6,61 @@ using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#embed-object
|
||||||
|
public partial record Embed(
|
||||||
|
string? Title,
|
||||||
|
string? Url,
|
||||||
|
DateTimeOffset? Timestamp,
|
||||||
|
Color? Color,
|
||||||
|
EmbedAuthor? Author,
|
||||||
|
string? Description,
|
||||||
|
IReadOnlyList<EmbedField> Fields,
|
||||||
|
EmbedImage? Thumbnail,
|
||||||
|
EmbedImage? Image,
|
||||||
|
EmbedFooter? Footer)
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/channel#embed-object
|
public PlainImageEmbedProjection? TryGetPlainImage() =>
|
||||||
public partial record Embed(
|
PlainImageEmbedProjection.TryResolve(this);
|
||||||
string? Title,
|
|
||||||
string? Url,
|
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
|
||||||
DateTimeOffset? Timestamp,
|
SpotifyTrackEmbedProjection.TryResolve(this);
|
||||||
Color? Color,
|
|
||||||
EmbedAuthor? Author,
|
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() =>
|
||||||
string? Description,
|
YouTubeVideoEmbedProjection.TryResolve(this);
|
||||||
IReadOnlyList<EmbedField> Fields,
|
}
|
||||||
EmbedImage? Thumbnail,
|
|
||||||
EmbedImage? Image,
|
public partial record Embed
|
||||||
EmbedFooter? Footer)
|
{
|
||||||
|
public static Embed Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
public PlainImageEmbedProjection? TryGetPlainImage() =>
|
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||||
PlainImageEmbedProjection.TryResolve(this);
|
var url = json.GetPropertyOrNull("url")?.GetStringOrNull();
|
||||||
|
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
||||||
|
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
||||||
|
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
||||||
|
|
||||||
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
|
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||||
SpotifyTrackEmbedProjection.TryResolve(this);
|
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||||
|
var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse);
|
||||||
|
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
||||||
|
|
||||||
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() =>
|
var fields =
|
||||||
YouTubeVideoEmbedProjection.TryResolve(this);
|
json.GetPropertyOrNull("fields")?.EnumerateArray().Select(EmbedField.Parse).ToArray() ??
|
||||||
}
|
Array.Empty<EmbedField>();
|
||||||
|
|
||||||
public partial record Embed
|
return new Embed(
|
||||||
{
|
title,
|
||||||
public static Embed Parse(JsonElement json)
|
url,
|
||||||
{
|
timestamp,
|
||||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
color,
|
||||||
var url = json.GetPropertyOrNull("url")?.GetStringOrNull();
|
author,
|
||||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
description,
|
||||||
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
fields,
|
||||||
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
thumbnail,
|
||||||
|
image,
|
||||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
footer
|
||||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
);
|
||||||
var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse);
|
|
||||||
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
|
||||||
|
|
||||||
var fields =
|
|
||||||
json.GetPropertyOrNull("fields")?.EnumerateArray().Select(EmbedField.Parse).ToArray() ??
|
|
||||||
Array.Empty<EmbedField>();
|
|
||||||
|
|
||||||
return new Embed(
|
|
||||||
title,
|
|
||||||
url,
|
|
||||||
timestamp,
|
|
||||||
color,
|
|
||||||
author,
|
|
||||||
description,
|
|
||||||
fields,
|
|
||||||
thumbnail,
|
|
||||||
image,
|
|
||||||
footer
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
{
|
|
||||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
|
||||||
public record EmbedAuthor(
|
|
||||||
string? Name,
|
|
||||||
string? Url,
|
|
||||||
string? IconUrl,
|
|
||||||
string? IconProxyUrl)
|
|
||||||
{
|
|
||||||
public static EmbedAuthor Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var name = json.GetPropertyOrNull("name")?.GetStringOrNull();
|
|
||||||
var url = json.GetPropertyOrNull("url")?.GetStringOrNull();
|
|
||||||
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetStringOrNull();
|
|
||||||
var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetStringOrNull();
|
|
||||||
|
|
||||||
return new EmbedAuthor(name, url, iconUrl, iconProxyUrl);
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||||
}
|
public record EmbedAuthor(
|
||||||
|
string? Name,
|
||||||
|
string? Url,
|
||||||
|
string? IconUrl,
|
||||||
|
string? IconProxyUrl)
|
||||||
|
{
|
||||||
|
public static EmbedAuthor Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var name = json.GetPropertyOrNull("name")?.GetStringOrNull();
|
||||||
|
var url = json.GetPropertyOrNull("url")?.GetStringOrNull();
|
||||||
|
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetStringOrNull();
|
||||||
|
var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetStringOrNull();
|
||||||
|
|
||||||
|
return new EmbedAuthor(name, url, iconUrl, iconProxyUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,21 +2,20 @@ using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
{
|
|
||||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
|
||||||
public record EmbedField(
|
|
||||||
string Name,
|
|
||||||
string Value,
|
|
||||||
bool IsInline)
|
|
||||||
{
|
|
||||||
public static EmbedField Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
|
||||||
var value = json.GetProperty("value").GetNonWhiteSpaceString();
|
|
||||||
var isInline = json.GetPropertyOrNull("inline")?.GetBoolean() ?? false;
|
|
||||||
|
|
||||||
return new EmbedField(name, value, isInline);
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||||
}
|
public record EmbedField(
|
||||||
|
string Name,
|
||||||
|
string Value,
|
||||||
|
bool IsInline)
|
||||||
|
{
|
||||||
|
public static EmbedField Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
||||||
|
var value = json.GetProperty("value").GetNonWhiteSpaceString();
|
||||||
|
var isInline = json.GetPropertyOrNull("inline")?.GetBoolean() ?? false;
|
||||||
|
|
||||||
|
return new EmbedField(name, value, isInline);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2,21 +2,20 @@ using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
{
|
|
||||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
|
||||||
public record EmbedFooter(
|
|
||||||
string Text,
|
|
||||||
string? IconUrl,
|
|
||||||
string? IconProxyUrl)
|
|
||||||
{
|
|
||||||
public static EmbedFooter Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var text = json.GetProperty("text").GetNonWhiteSpaceString();
|
|
||||||
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetStringOrNull();
|
|
||||||
var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetStringOrNull();
|
|
||||||
|
|
||||||
return new EmbedFooter(text, iconUrl, iconProxyUrl);
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||||
}
|
public record EmbedFooter(
|
||||||
|
string Text,
|
||||||
|
string? IconUrl,
|
||||||
|
string? IconProxyUrl)
|
||||||
|
{
|
||||||
|
public static EmbedFooter Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var text = json.GetProperty("text").GetNonWhiteSpaceString();
|
||||||
|
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetStringOrNull();
|
||||||
|
var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetStringOrNull();
|
||||||
|
|
||||||
|
return new EmbedFooter(text, iconUrl, iconProxyUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
{
|
|
||||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
|
||||||
public record EmbedImage(
|
|
||||||
string? Url,
|
|
||||||
string? ProxyUrl,
|
|
||||||
int? Width,
|
|
||||||
int? Height)
|
|
||||||
{
|
|
||||||
public static EmbedImage Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var url = json.GetPropertyOrNull("url")?.GetStringOrNull();
|
|
||||||
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetStringOrNull();
|
|
||||||
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
|
||||||
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
|
||||||
|
|
||||||
return new EmbedImage(url, proxyUrl, width, height);
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||||
}
|
public record EmbedImage(
|
||||||
|
string? Url,
|
||||||
|
string? ProxyUrl,
|
||||||
|
int? Width,
|
||||||
|
int? Height)
|
||||||
|
{
|
||||||
|
public static EmbedImage Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var url = json.GetPropertyOrNull("url")?.GetStringOrNull();
|
||||||
|
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetStringOrNull();
|
||||||
|
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
||||||
|
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
||||||
|
|
||||||
|
return new EmbedImage(url, proxyUrl, width, height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,32 +3,31 @@ using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
|
||||||
|
public record PlainImageEmbedProjection(string Url)
|
||||||
{
|
{
|
||||||
public record PlainImageEmbedProjection(string Url)
|
public static PlainImageEmbedProjection? TryResolve(Embed embed)
|
||||||
{
|
{
|
||||||
public static PlainImageEmbedProjection? TryResolve(Embed embed)
|
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Has to be an embed without any data (except URL and image)
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title) ||
|
||||||
|
embed.Timestamp is not null ||
|
||||||
|
embed.Author is not null ||
|
||||||
|
!string.IsNullOrWhiteSpace(embed.Description) ||
|
||||||
|
embed.Fields.Any() ||
|
||||||
|
embed.Footer is not null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
// Has to be an embed without any data (except URL and image)
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title) ||
|
|
||||||
embed.Timestamp is not null ||
|
|
||||||
embed.Author is not null ||
|
|
||||||
!string.IsNullOrWhiteSpace(embed.Description) ||
|
|
||||||
embed.Fields.Any() ||
|
|
||||||
embed.Footer is not null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Has to be an image file
|
|
||||||
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new PlainImageEmbedProjection(embed.Url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Has to be an image file
|
||||||
|
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new PlainImageEmbedProjection(embed.Url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,34 +1,33 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
|
||||||
|
public partial record SpotifyTrackEmbedProjection(string TrackId)
|
||||||
{
|
{
|
||||||
public partial record SpotifyTrackEmbedProjection(string TrackId)
|
public string Url => $"https://open.spotify.com/embed/track/{TrackId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record SpotifyTrackEmbedProjection
|
||||||
|
{
|
||||||
|
private static string? TryParseTrackId(string embedUrl)
|
||||||
{
|
{
|
||||||
public string Url => $"https://open.spotify.com/embed/track/{TrackId}";
|
// https://open.spotify.com/track/1LHZMWefF9502NPfArRfvP?si=3efac6ce9be04f0a
|
||||||
|
var trackId = Regex.Match(embedUrl, @"spotify\.com/track/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(trackId))
|
||||||
|
return trackId;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record SpotifyTrackEmbedProjection
|
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
|
||||||
{
|
{
|
||||||
private static string? TryParseTrackId(string embedUrl)
|
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
|
||||||
// https://open.spotify.com/track/1LHZMWefF9502NPfArRfvP?si=3efac6ce9be04f0a
|
|
||||||
var trackId = Regex.Match(embedUrl, @"spotify\.com/track/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
|
||||||
if (!string.IsNullOrWhiteSpace(trackId))
|
|
||||||
return trackId;
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
|
var trackId = TryParseTrackId(embed.Url);
|
||||||
{
|
if (string.IsNullOrWhiteSpace(trackId))
|
||||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
var trackId = TryParseTrackId(embed.Url);
|
return new SpotifyTrackEmbedProjection(trackId);
|
||||||
if (string.IsNullOrWhiteSpace(trackId))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new SpotifyTrackEmbedProjection(trackId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,49 +1,48 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
|
||||||
|
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||||
{
|
{
|
||||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
public string Url => $"https://www.youtube.com/embed/{VideoId}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record YouTubeVideoEmbedProjection
|
||||||
|
{
|
||||||
|
// Adapted from YoutubeExplode
|
||||||
|
// https://github.com/Tyrrrz/YoutubeExplode/blob/5be164be20019783913f76fcc98f18c65aebe9f0/YoutubeExplode/Videos/VideoId.cs#L34-L64
|
||||||
|
private static string? TryParseVideoId(string embedUrl)
|
||||||
{
|
{
|
||||||
public string Url => $"https://www.youtube.com/embed/{VideoId}";
|
// Regular URL
|
||||||
|
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
||||||
|
var regularMatch = Regex.Match(embedUrl, @"youtube\..+?/watch.*?v=(.*?)(?:&|/|$)").Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(regularMatch))
|
||||||
|
return regularMatch;
|
||||||
|
|
||||||
|
// Short URL
|
||||||
|
// https://youtu.be/yIVRs6YSbOM
|
||||||
|
var shortMatch = Regex.Match(embedUrl, @"youtu\.be/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(shortMatch))
|
||||||
|
return shortMatch;
|
||||||
|
|
||||||
|
// Embed URL
|
||||||
|
// https://www.youtube.com/embed/yIVRs6YSbOM
|
||||||
|
var embedMatch = Regex.Match(embedUrl, @"youtube\..+?/embed/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedMatch))
|
||||||
|
return embedMatch;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record YouTubeVideoEmbedProjection
|
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
|
||||||
{
|
{
|
||||||
// Adapted from YoutubeExplode
|
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||||
// https://github.com/Tyrrrz/YoutubeExplode/blob/5be164be20019783913f76fcc98f18c65aebe9f0/YoutubeExplode/Videos/VideoId.cs#L34-L64
|
|
||||||
private static string? TryParseVideoId(string embedUrl)
|
|
||||||
{
|
|
||||||
// Regular URL
|
|
||||||
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
|
||||||
var regularMatch = Regex.Match(embedUrl, @"youtube\..+?/watch.*?v=(.*?)(?:&|/|$)").Groups[1].Value;
|
|
||||||
if (!string.IsNullOrWhiteSpace(regularMatch))
|
|
||||||
return regularMatch;
|
|
||||||
|
|
||||||
// Short URL
|
|
||||||
// https://youtu.be/yIVRs6YSbOM
|
|
||||||
var shortMatch = Regex.Match(embedUrl, @"youtu\.be/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
|
||||||
if (!string.IsNullOrWhiteSpace(shortMatch))
|
|
||||||
return shortMatch;
|
|
||||||
|
|
||||||
// Embed URL
|
|
||||||
// https://www.youtube.com/embed/yIVRs6YSbOM
|
|
||||||
var embedMatch = Regex.Match(embedUrl, @"youtube\..+?/embed/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedMatch))
|
|
||||||
return embedMatch;
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
|
var videoId = TryParseVideoId(embed.Url);
|
||||||
{
|
if (string.IsNullOrWhiteSpace(videoId))
|
||||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
return null;
|
||||||
return null;
|
|
||||||
|
|
||||||
var videoId = TryParseVideoId(embed.Url);
|
return new YouTubeVideoEmbedProjection(videoId);
|
||||||
if (string.IsNullOrWhiteSpace(videoId))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new YouTubeVideoEmbedProjection(videoId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,57 +4,56 @@ using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
||||||
|
public partial record Emoji(
|
||||||
|
// Only present on custom emoji
|
||||||
|
string? Id,
|
||||||
|
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||||
|
string Name,
|
||||||
|
bool IsAnimated,
|
||||||
|
string ImageUrl)
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||||
public partial record Emoji(
|
public string Code => !string.IsNullOrWhiteSpace(Id)
|
||||||
// Only present on custom emoji
|
? Name
|
||||||
string? Id,
|
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
}
|
||||||
string Name,
|
|
||||||
bool IsAnimated,
|
public partial record Emoji
|
||||||
string ImageUrl)
|
{
|
||||||
|
private static string GetTwemojiName(string name) => string.Join("-",
|
||||||
|
name
|
||||||
|
.GetRunes()
|
||||||
|
// Variant selector rune is skipped in Twemoji names
|
||||||
|
.Where(r => r.Value != 0xfe0f)
|
||||||
|
.Select(r => r.Value.ToString("x"))
|
||||||
|
);
|
||||||
|
|
||||||
|
public static string GetImageUrl(string? id, string name, bool isAnimated)
|
||||||
{
|
{
|
||||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
// Custom emoji
|
||||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
? Name
|
{
|
||||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
return isAnimated
|
||||||
|
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
||||||
|
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard emoji
|
||||||
|
var twemojiName = GetTwemojiName(name);
|
||||||
|
return $"https://twemoji.maxcdn.com/2/svg/{twemojiName}.svg";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Emoji
|
public static Emoji Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
private static string GetTwemojiName(string name) => string.Join("-",
|
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceString();
|
||||||
name
|
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
||||||
.GetRunes()
|
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
|
||||||
// Variant selector rune is skipped in Twemoji names
|
|
||||||
.Where(r => r.Value != 0xfe0f)
|
|
||||||
.Select(r => r.Value.ToString("x"))
|
|
||||||
);
|
|
||||||
|
|
||||||
public static string GetImageUrl(string? id, string name, bool isAnimated)
|
var imageUrl = GetImageUrl(id, name, isAnimated);
|
||||||
{
|
|
||||||
// Custom emoji
|
|
||||||
if (!string.IsNullOrWhiteSpace(id))
|
|
||||||
{
|
|
||||||
return isAnimated
|
|
||||||
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
|
||||||
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard emoji
|
return new Emoji(id, name, isAnimated, imageUrl);
|
||||||
var twemojiName = GetTwemojiName(name);
|
|
||||||
return $"https://twemoji.maxcdn.com/2/svg/{twemojiName}.svg";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Emoji Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceString();
|
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
|
||||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
|
|
||||||
|
|
||||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
|
||||||
|
|
||||||
return new Emoji(id, name, isAnimated, imageUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,34 +3,33 @@ using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||||
|
public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
public static Guild DirectMessages { get; } = new(
|
||||||
public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
Snowflake.Zero,
|
||||||
|
"Direct Messages",
|
||||||
|
GetDefaultIconUrl()
|
||||||
|
);
|
||||||
|
|
||||||
|
private static string GetDefaultIconUrl() =>
|
||||||
|
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
|
|
||||||
|
private static string GetIconUrl(Snowflake id, string iconHash) =>
|
||||||
|
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
|
||||||
|
|
||||||
|
public static Guild Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
public static Guild DirectMessages { get; } = new(
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
Snowflake.Zero,
|
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
||||||
"Direct Messages",
|
var iconHash = json.GetPropertyOrNull("icon")?.GetStringOrNull();
|
||||||
GetDefaultIconUrl()
|
|
||||||
);
|
|
||||||
|
|
||||||
private static string GetDefaultIconUrl() =>
|
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
||||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
? GetIconUrl(id, iconHash)
|
||||||
|
: GetDefaultIconUrl();
|
||||||
|
|
||||||
private static string GetIconUrl(Snowflake id, string iconHash) =>
|
return new Guild(id, name, iconUrl);
|
||||||
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
|
|
||||||
|
|
||||||
public static Guild Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
|
||||||
var iconHash = json.GetPropertyOrNull("icon")?.GetStringOrNull();
|
|
||||||
|
|
||||||
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
|
||||||
? GetIconUrl(id, iconHash)
|
|
||||||
: GetDefaultIconUrl();
|
|
||||||
|
|
||||||
return new Guild(id, name, iconUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,42 +6,41 @@ using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/guild#guild-member-object
|
||||||
|
public partial record Member(
|
||||||
|
User User,
|
||||||
|
string Nick,
|
||||||
|
IReadOnlyList<Snowflake> RoleIds) : IHasId
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/guild#guild-member-object
|
public Snowflake Id => User.Id;
|
||||||
public partial record Member(
|
}
|
||||||
User User,
|
|
||||||
string Nick,
|
|
||||||
IReadOnlyList<Snowflake> RoleIds) : IHasId
|
|
||||||
{
|
|
||||||
public Snowflake Id => User.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial record Member
|
public partial record Member
|
||||||
|
{
|
||||||
|
public static Member CreateForUser(User user) => new(
|
||||||
|
user,
|
||||||
|
user.Name,
|
||||||
|
Array.Empty<Snowflake>()
|
||||||
|
);
|
||||||
|
|
||||||
|
public static Member Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
public static Member CreateForUser(User user) => new(
|
var user = json.GetProperty("user").Pipe(User.Parse);
|
||||||
|
var nick = json.GetPropertyOrNull("nick")?.GetStringOrNull();
|
||||||
|
|
||||||
|
var roleIds = json
|
||||||
|
.GetPropertyOrNull("roles")?
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetNonWhiteSpaceString())
|
||||||
|
.Select(Snowflake.Parse)
|
||||||
|
.ToArray() ?? Array.Empty<Snowflake>();
|
||||||
|
|
||||||
|
return new Member(
|
||||||
user,
|
user,
|
||||||
user.Name,
|
nick ?? user.Name,
|
||||||
Array.Empty<Snowflake>()
|
roleIds
|
||||||
);
|
);
|
||||||
|
|
||||||
public static Member Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var user = json.GetProperty("user").Pipe(User.Parse);
|
|
||||||
var nick = json.GetPropertyOrNull("nick")?.GetStringOrNull();
|
|
||||||
|
|
||||||
var roleIds = json
|
|
||||||
.GetPropertyOrNull("roles")?
|
|
||||||
.EnumerateArray()
|
|
||||||
.Select(j => j.GetNonWhiteSpaceString())
|
|
||||||
.Select(Snowflake.Parse)
|
|
||||||
.ToArray() ?? Array.Empty<Snowflake>();
|
|
||||||
|
|
||||||
return new Member(
|
|
||||||
user,
|
|
||||||
nick ?? user.Name,
|
|
||||||
roleIds
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7,83 +7,82 @@ using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-object
|
||||||
|
public record Message(
|
||||||
|
Snowflake Id,
|
||||||
|
MessageKind Kind,
|
||||||
|
User Author,
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
DateTimeOffset? EditedTimestamp,
|
||||||
|
DateTimeOffset? CallEndedTimestamp,
|
||||||
|
bool IsPinned,
|
||||||
|
string Content,
|
||||||
|
IReadOnlyList<Attachment> Attachments,
|
||||||
|
IReadOnlyList<Embed> Embeds,
|
||||||
|
IReadOnlyList<Reaction> Reactions,
|
||||||
|
IReadOnlyList<User> MentionedUsers,
|
||||||
|
MessageReference? Reference,
|
||||||
|
Message? ReferencedMessage) : IHasId
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object
|
public static Message Parse(JsonElement json)
|
||||||
public record Message(
|
|
||||||
Snowflake Id,
|
|
||||||
MessageKind Kind,
|
|
||||||
User Author,
|
|
||||||
DateTimeOffset Timestamp,
|
|
||||||
DateTimeOffset? EditedTimestamp,
|
|
||||||
DateTimeOffset? CallEndedTimestamp,
|
|
||||||
bool IsPinned,
|
|
||||||
string Content,
|
|
||||||
IReadOnlyList<Attachment> Attachments,
|
|
||||||
IReadOnlyList<Embed> Embeds,
|
|
||||||
IReadOnlyList<Reaction> Reactions,
|
|
||||||
IReadOnlyList<User> MentionedUsers,
|
|
||||||
MessageReference? Reference,
|
|
||||||
Message? ReferencedMessage) : IHasId
|
|
||||||
{
|
{
|
||||||
public static Message Parse(JsonElement json)
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
|
var author = json.GetProperty("author").Pipe(User.Parse);
|
||||||
|
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||||
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||||
|
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")
|
||||||
|
?.GetDateTimeOffset();
|
||||||
|
var kind = (MessageKind)json.GetProperty("type").GetInt32();
|
||||||
|
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
||||||
|
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
||||||
|
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||||
|
|
||||||
|
var content = kind switch
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
MessageKind.RecipientAdd => "Added a recipient.",
|
||||||
var author = json.GetProperty("author").Pipe(User.Parse);
|
MessageKind.RecipientRemove => "Removed a recipient.",
|
||||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
MessageKind.Call =>
|
||||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
$"Started a call that lasted {callEndedTimestamp?.Pipe(t => t - timestamp).Pipe(t => (int)t.TotalMinutes) ?? 0} minutes.",
|
||||||
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")
|
MessageKind.ChannelNameChange => "Changed the channel name.",
|
||||||
?.GetDateTimeOffset();
|
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||||
var kind = (MessageKind)json.GetProperty("type").GetInt32();
|
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||||
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
|
||||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
};
|
||||||
|
|
||||||
var content = kind switch
|
var attachments =
|
||||||
{
|
json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(Attachment.Parse).ToArray() ??
|
||||||
MessageKind.RecipientAdd => "Added a recipient.",
|
Array.Empty<Attachment>();
|
||||||
MessageKind.RecipientRemove => "Removed a recipient.",
|
|
||||||
MessageKind.Call =>
|
|
||||||
$"Started a call that lasted {callEndedTimestamp?.Pipe(t => t - timestamp).Pipe(t => (int)t.TotalMinutes) ?? 0} minutes.",
|
|
||||||
MessageKind.ChannelNameChange => "Changed the channel name.",
|
|
||||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
|
||||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
|
||||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
|
||||||
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
|
|
||||||
};
|
|
||||||
|
|
||||||
var attachments =
|
var embeds =
|
||||||
json.GetPropertyOrNull("attachments")?.EnumerateArray().Select(Attachment.Parse).ToArray() ??
|
json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(Embed.Parse).ToArray() ??
|
||||||
Array.Empty<Attachment>();
|
Array.Empty<Embed>();
|
||||||
|
|
||||||
var embeds =
|
var reactions =
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArray().Select(Embed.Parse).ToArray() ??
|
json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(Reaction.Parse).ToArray() ??
|
||||||
Array.Empty<Embed>();
|
Array.Empty<Reaction>();
|
||||||
|
|
||||||
var reactions =
|
var mentionedUsers =
|
||||||
json.GetPropertyOrNull("reactions")?.EnumerateArray().Select(Reaction.Parse).ToArray() ??
|
json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(User.Parse).ToArray() ??
|
||||||
Array.Empty<Reaction>();
|
Array.Empty<User>();
|
||||||
|
|
||||||
var mentionedUsers =
|
return new Message(
|
||||||
json.GetPropertyOrNull("mentions")?.EnumerateArray().Select(User.Parse).ToArray() ??
|
id,
|
||||||
Array.Empty<User>();
|
kind,
|
||||||
|
author,
|
||||||
return new Message(
|
timestamp,
|
||||||
id,
|
editedTimestamp,
|
||||||
kind,
|
callEndedTimestamp,
|
||||||
author,
|
isPinned,
|
||||||
timestamp,
|
content,
|
||||||
editedTimestamp,
|
attachments,
|
||||||
callEndedTimestamp,
|
embeds,
|
||||||
isPinned,
|
reactions,
|
||||||
content,
|
mentionedUsers,
|
||||||
attachments,
|
messageReference,
|
||||||
embeds,
|
referencedMessage
|
||||||
reactions,
|
);
|
||||||
mentionedUsers,
|
|
||||||
messageReference,
|
|
||||||
referencedMessage
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
|
public enum MessageKind
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
Default = 0,
|
||||||
public enum MessageKind
|
RecipientAdd = 1,
|
||||||
{
|
RecipientRemove = 2,
|
||||||
Default = 0,
|
Call = 3,
|
||||||
RecipientAdd = 1,
|
ChannelNameChange = 4,
|
||||||
RecipientRemove = 2,
|
ChannelIconChange = 5,
|
||||||
Call = 3,
|
ChannelPinnedMessage = 6,
|
||||||
ChannelNameChange = 4,
|
GuildMemberJoin = 7,
|
||||||
ChannelIconChange = 5,
|
Reply = 19
|
||||||
ChannelPinnedMessage = 6,
|
|
||||||
GuildMemberJoin = 7,
|
|
||||||
Reply = 19
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,18 +2,17 @@ using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
{
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
|
||||||
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
|
|
||||||
{
|
|
||||||
public static MessageReference Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var messageId = json.GetPropertyOrNull("message_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
|
||||||
var channelId = json.GetPropertyOrNull("channel_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
|
||||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
|
||||||
|
|
||||||
return new MessageReference(messageId, channelId, guildId);
|
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||||
}
|
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
|
||||||
|
{
|
||||||
|
public static MessageReference Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var messageId = json.GetPropertyOrNull("message_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
|
var channelId = json.GetPropertyOrNull("channel_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
|
var guildId = json.GetPropertyOrNull("guild_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
return new MessageReference(messageId, channelId, guildId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
{
|
|
||||||
// https://discord.com/developers/docs/resources/channel#reaction-object
|
|
||||||
public record Reaction(Emoji Emoji, int Count)
|
|
||||||
{
|
|
||||||
public static Reaction Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse);
|
|
||||||
var count = json.GetProperty("count").GetInt32();
|
|
||||||
|
|
||||||
return new Reaction(emoji, count);
|
// https://discord.com/developers/docs/resources/channel#reaction-object
|
||||||
}
|
public record Reaction(Emoji Emoji, int Count)
|
||||||
|
{
|
||||||
|
public static Reaction Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse);
|
||||||
|
var count = json.GetProperty("count").GetInt32();
|
||||||
|
|
||||||
|
return new Reaction(emoji, count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,25 +4,24 @@ using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/topics/permissions#role-object
|
||||||
|
public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHasId
|
||||||
{
|
{
|
||||||
// https://discord.com/developers/docs/topics/permissions#role-object
|
public static Role Parse(JsonElement json)
|
||||||
public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHasId
|
|
||||||
{
|
{
|
||||||
public static Role Parse(JsonElement json)
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
{
|
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var position = json.GetProperty("position").GetInt32();
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
|
||||||
var position = json.GetProperty("position").GetInt32();
|
|
||||||
|
|
||||||
var color = json
|
var color = json
|
||||||
.GetPropertyOrNull("color")?
|
.GetPropertyOrNull("color")?
|
||||||
.GetInt32()
|
.GetInt32()
|
||||||
.Pipe(System.Drawing.Color.FromArgb)
|
.Pipe(System.Drawing.Color.FromArgb)
|
||||||
.ResetAlpha()
|
.ResetAlpha()
|
||||||
.NullIf(c => c.ToRgb() <= 0);
|
.NullIf(c => c.ToRgb() <= 0);
|
||||||
|
|
||||||
return new Role(id, name, position, color);
|
return new Role(id, name, position, color);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,48 +4,47 @@ using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
{
|
|
||||||
// https://discord.com/developers/docs/resources/user#user-object
|
|
||||||
public partial record User(
|
|
||||||
Snowflake Id,
|
|
||||||
bool IsBot,
|
|
||||||
int Discriminator,
|
|
||||||
string Name,
|
|
||||||
string AvatarUrl) : IHasId
|
|
||||||
{
|
|
||||||
public string DiscriminatorFormatted => $"{Discriminator:0000}";
|
|
||||||
|
|
||||||
public string FullName => $"{Name}#{DiscriminatorFormatted}";
|
// https://discord.com/developers/docs/resources/user#user-object
|
||||||
|
public partial record User(
|
||||||
|
Snowflake Id,
|
||||||
|
bool IsBot,
|
||||||
|
int Discriminator,
|
||||||
|
string Name,
|
||||||
|
string AvatarUrl) : IHasId
|
||||||
|
{
|
||||||
|
public string DiscriminatorFormatted => $"{Discriminator:0000}";
|
||||||
|
|
||||||
|
public string FullName => $"{Name}#{DiscriminatorFormatted}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record User
|
||||||
|
{
|
||||||
|
private static string GetDefaultAvatarUrl(int discriminator) =>
|
||||||
|
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
||||||
|
|
||||||
|
private static string GetAvatarUrl(Snowflake id, string avatarHash)
|
||||||
|
{
|
||||||
|
var extension = avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
||||||
|
? "gif"
|
||||||
|
: "png";
|
||||||
|
|
||||||
|
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=128";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record User
|
public static User Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
private static string GetDefaultAvatarUrl(int discriminator) =>
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
|
||||||
|
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
||||||
|
var name = json.GetProperty("username").GetNonWhiteSpaceString();
|
||||||
|
var avatarHash = json.GetPropertyOrNull("avatar")?.GetStringOrNull();
|
||||||
|
|
||||||
private static string GetAvatarUrl(Snowflake id, string avatarHash)
|
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
||||||
{
|
? GetAvatarUrl(id, avatarHash)
|
||||||
var extension = avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
: GetDefaultAvatarUrl(discriminator);
|
||||||
? "gif"
|
|
||||||
: "png";
|
|
||||||
|
|
||||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=128";
|
return new User(id, isBot, discriminator, name, avatarUrl);
|
||||||
}
|
|
||||||
|
|
||||||
public static User Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
|
||||||
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
|
|
||||||
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
|
||||||
var name = json.GetProperty("username").GetNonWhiteSpaceString();
|
|
||||||
var avatarHash = json.GetPropertyOrNull("avatar")?.GetStringOrNull();
|
|
||||||
|
|
||||||
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
|
||||||
? GetAvatarUrl(id, avatarHash)
|
|
||||||
: GetDefaultAvatarUrl(discriminator);
|
|
||||||
|
|
||||||
return new User(id, isBot, discriminator, name, avatarUrl);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -14,289 +14,288 @@ using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Http;
|
using JsonExtensions.Http;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
public class DiscordClient
|
||||||
{
|
{
|
||||||
public class DiscordClient
|
private readonly AuthToken _token;
|
||||||
|
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
|
||||||
|
|
||||||
|
public DiscordClient(AuthToken token) => _token = token;
|
||||||
|
|
||||||
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||||
|
string url,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
private readonly AuthToken _token;
|
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||||
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
|
|
||||||
|
|
||||||
public DiscordClient(AuthToken token) => _token = token;
|
|
||||||
|
|
||||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
|
||||||
string url,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
|
request.Headers.Authorization = _token.GetAuthenticationHeader();
|
||||||
|
|
||||||
|
return await Http.Client.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
innerCancellationToken
|
||||||
|
);
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<JsonElement> GetJsonResponseAsync(
|
||||||
|
string url,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var response = await GetResponseAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw response.StatusCode switch
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(),
|
||||||
request.Headers.Authorization = _token.GetAuthenticationHeader();
|
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(),
|
||||||
|
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(url),
|
||||||
return await Http.Client.SendAsync(
|
_ => DiscordChatExporterException.FailedHttpRequest(response)
|
||||||
request,
|
};
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
|
||||||
innerCancellationToken
|
|
||||||
);
|
|
||||||
}, cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<JsonElement> GetJsonResponseAsync(
|
return await response.Content.ReadAsJsonAsync(cancellationToken);
|
||||||
string url,
|
}
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
using var response = await GetResponseAsync(url, cancellationToken);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(
|
||||||
{
|
string url,
|
||||||
throw response.StatusCode switch
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(),
|
using var response = await GetResponseAsync(url, cancellationToken);
|
||||||
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(),
|
|
||||||
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(url),
|
|
||||||
_ => DiscordChatExporterException.FailedHttpRequest(response)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.Content.ReadAsJsonAsync(cancellationToken);
|
return response.IsSuccessStatusCode
|
||||||
}
|
? await response.Content.ReadAsJsonAsync(cancellationToken)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(
|
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
|
||||||
string url,
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
CancellationToken cancellationToken = default)
|
{
|
||||||
{
|
yield return Guild.DirectMessages;
|
||||||
using var response = await GetResponseAsync(url, cancellationToken);
|
|
||||||
|
|
||||||
return response.IsSuccessStatusCode
|
var currentAfter = Snowflake.Zero;
|
||||||
? await response.Content.ReadAsJsonAsync(cancellationToken)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
|
while (true)
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
yield return Guild.DirectMessages;
|
|
||||||
|
|
||||||
var currentAfter = Snowflake.Zero;
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var url = new UrlBuilder()
|
|
||||||
.SetPath("users/@me/guilds")
|
|
||||||
.SetQueryParameter("limit", "100")
|
|
||||||
.SetQueryParameter("after", currentAfter.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
|
||||||
|
|
||||||
var isEmpty = true;
|
|
||||||
foreach (var guild in response.EnumerateArray().Select(Guild.Parse))
|
|
||||||
{
|
|
||||||
yield return guild;
|
|
||||||
|
|
||||||
currentAfter = guild.Id;
|
|
||||||
isEmpty = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmpty)
|
|
||||||
yield break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Guild> GetGuildAsync(
|
|
||||||
Snowflake guildId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
|
||||||
return Guild.DirectMessages;
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync($"guilds/{guildId}", cancellationToken);
|
|
||||||
return Guild.Parse(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(
|
|
||||||
Snowflake guildId,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
|
||||||
{
|
|
||||||
var response = await GetJsonResponseAsync("users/@me/channels", cancellationToken);
|
|
||||||
foreach (var channelJson in response.EnumerateArray())
|
|
||||||
yield return Channel.Parse(channelJson);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels", cancellationToken);
|
|
||||||
|
|
||||||
var responseOrdered = response
|
|
||||||
.EnumerateArray()
|
|
||||||
.OrderBy(j => j.GetProperty("position").GetInt32())
|
|
||||||
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var categories = responseOrdered
|
|
||||||
.Where(j => j.GetProperty("type").GetInt32() == (int) ChannelKind.GuildCategory)
|
|
||||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
|
||||||
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
|
||||||
|
|
||||||
var position = 0;
|
|
||||||
|
|
||||||
foreach (var channelJson in responseOrdered)
|
|
||||||
{
|
|
||||||
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetStringOrNull();
|
|
||||||
|
|
||||||
var category = !string.IsNullOrWhiteSpace(parentId)
|
|
||||||
? categories.GetValueOrDefault(parentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var channel = Channel.Parse(channelJson, category, position);
|
|
||||||
|
|
||||||
position++;
|
|
||||||
|
|
||||||
yield return channel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
|
|
||||||
Snowflake guildId,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
|
||||||
yield break;
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
|
|
||||||
|
|
||||||
foreach (var roleJson in response.EnumerateArray())
|
|
||||||
yield return Role.Parse(roleJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Member> GetGuildMemberAsync(
|
|
||||||
Snowflake guildId,
|
|
||||||
User user,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
|
||||||
return Member.CreateForUser(user);
|
|
||||||
|
|
||||||
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{user.Id}", cancellationToken);
|
|
||||||
return response?.Pipe(Member.Parse) ?? Member.CreateForUser(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(
|
|
||||||
Snowflake channelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
|
||||||
return ChannelCategory.Parse(response);
|
|
||||||
}
|
|
||||||
// In some cases, the Discord API returns an empty body when requesting channel category.
|
|
||||||
// Instead, we use an empty channel category as a fallback.
|
|
||||||
catch (DiscordChatExporterException)
|
|
||||||
{
|
|
||||||
return ChannelCategory.Unknown;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Channel> GetChannelAsync(
|
|
||||||
Snowflake channelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
|
||||||
|
|
||||||
var parentId = response.GetPropertyOrNull("parent_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
|
||||||
|
|
||||||
var category = parentId is not null
|
|
||||||
? await GetChannelCategoryAsync(parentId.Value, cancellationToken)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Channel.Parse(response, category);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
|
||||||
Snowflake channelId,
|
|
||||||
Snowflake? before = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
{
|
||||||
var url = new UrlBuilder()
|
var url = new UrlBuilder()
|
||||||
.SetPath($"channels/{channelId}/messages")
|
.SetPath("users/@me/guilds")
|
||||||
.SetQueryParameter("limit", "1")
|
.SetQueryParameter("limit", "100")
|
||||||
.SetQueryParameter("before", before?.ToString())
|
.SetQueryParameter("after", currentAfter.ToString())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
var isEmpty = true;
|
||||||
Snowflake channelId,
|
foreach (var guild in response.EnumerateArray().Select(Guild.Parse))
|
||||||
Snowflake? after = null,
|
{
|
||||||
Snowflake? before = null,
|
yield return guild;
|
||||||
IProgress<double>? progress = null,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
currentAfter = guild.Id;
|
||||||
|
isEmpty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty)
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Guild> GetGuildAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
|
return Guild.DirectMessages;
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync($"guilds/{guildId}", cancellationToken);
|
||||||
|
return Guild.Parse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
{
|
{
|
||||||
// Get the last message in the specified range.
|
var response = await GetJsonResponseAsync("users/@me/channels", cancellationToken);
|
||||||
// This snapshots the boundaries, which means that messages posted after the export started
|
foreach (var channelJson in response.EnumerateArray())
|
||||||
// will not appear in the output.
|
yield return Channel.Parse(channelJson);
|
||||||
// Additionally, it provides the date of the last message, which is used to calculate progress.
|
}
|
||||||
var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken);
|
else
|
||||||
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
|
{
|
||||||
|
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels", cancellationToken);
|
||||||
|
|
||||||
|
var responseOrdered = response
|
||||||
|
.EnumerateArray()
|
||||||
|
.OrderBy(j => j.GetProperty("position").GetInt32())
|
||||||
|
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var categories = responseOrdered
|
||||||
|
.Where(j => j.GetProperty("type").GetInt32() == (int) ChannelKind.GuildCategory)
|
||||||
|
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
||||||
|
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var position = 0;
|
||||||
|
|
||||||
|
foreach (var channelJson in responseOrdered)
|
||||||
|
{
|
||||||
|
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetStringOrNull();
|
||||||
|
|
||||||
|
var category = !string.IsNullOrWhiteSpace(parentId)
|
||||||
|
? categories.GetValueOrDefault(parentId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var channel = Channel.Parse(channelJson, category, position);
|
||||||
|
|
||||||
|
position++;
|
||||||
|
|
||||||
|
yield return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
|
||||||
|
|
||||||
|
foreach (var roleJson in response.EnumerateArray())
|
||||||
|
yield return Role.Parse(roleJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Member> GetGuildMemberAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
User user,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
|
return Member.CreateForUser(user);
|
||||||
|
|
||||||
|
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{user.Id}", cancellationToken);
|
||||||
|
return response?.Pipe(Member.Parse) ?? Member.CreateForUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||||
|
return ChannelCategory.Parse(response);
|
||||||
|
}
|
||||||
|
// In some cases, the Discord API returns an empty body when requesting channel category.
|
||||||
|
// Instead, we use an empty channel category as a fallback.
|
||||||
|
catch (DiscordChatExporterException)
|
||||||
|
{
|
||||||
|
return ChannelCategory.Unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Channel> GetChannelAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||||
|
|
||||||
|
var parentId = response.GetPropertyOrNull("parent_id")?.GetStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
var category = parentId is not null
|
||||||
|
? await GetChannelCategoryAsync(parentId.Value, cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Channel.Parse(response, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake? before = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channelId}/messages")
|
||||||
|
.SetQueryParameter("limit", "1")
|
||||||
|
.SetQueryParameter("before", before?.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
|
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake? after = null,
|
||||||
|
Snowflake? before = null,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Get the last message in the specified range.
|
||||||
|
// This snapshots the boundaries, which means that messages posted after the export started
|
||||||
|
// will not appear in the output.
|
||||||
|
// Additionally, it provides the date of the last message, which is used to calculate progress.
|
||||||
|
var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken);
|
||||||
|
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// Keep track of first message in range in order to calculate progress
|
||||||
|
var firstMessage = default(Message);
|
||||||
|
var currentAfter = after ?? Snowflake.Zero;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channelId}/messages")
|
||||||
|
.SetQueryParameter("limit", "100")
|
||||||
|
.SetQueryParameter("after", currentAfter.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
var messages = response
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(Message.Parse)
|
||||||
|
.Reverse() // reverse because messages appear newest first
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||||
|
if (!messages.Any())
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
// Keep track of first message in range in order to calculate progress
|
foreach (var message in messages)
|
||||||
var firstMessage = default(Message);
|
|
||||||
var currentAfter = after ?? Snowflake.Zero;
|
|
||||||
|
|
||||||
while (true)
|
|
||||||
{
|
{
|
||||||
var url = new UrlBuilder()
|
firstMessage ??= message;
|
||||||
.SetPath($"channels/{channelId}/messages")
|
|
||||||
.SetQueryParameter("limit", "100")
|
|
||||||
.SetQueryParameter("after", currentAfter.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
// Ensure messages are in range (take into account that last message could have been deleted)
|
||||||
|
if (message.Timestamp > lastMessage.Timestamp)
|
||||||
var messages = response
|
|
||||||
.EnumerateArray()
|
|
||||||
.Select(Message.Parse)
|
|
||||||
.Reverse() // reverse because messages appear newest first
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
|
||||||
if (!messages.Any())
|
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
foreach (var message in messages)
|
// Report progress based on the duration of exported messages divided by total
|
||||||
|
if (progress is not null)
|
||||||
{
|
{
|
||||||
firstMessage ??= message;
|
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
|
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
|
|
||||||
// Ensure messages are in range (take into account that last message could have been deleted)
|
if (totalDuration > TimeSpan.Zero)
|
||||||
if (message.Timestamp > lastMessage.Timestamp)
|
|
||||||
yield break;
|
|
||||||
|
|
||||||
// Report progress based on the duration of exported messages divided by total
|
|
||||||
if (progress is not null)
|
|
||||||
{
|
{
|
||||||
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
progress.Report(exportedDuration / totalDuration);
|
||||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
}
|
||||||
|
// Avoid division by zero if all messages have the exact same timestamp
|
||||||
if (totalDuration > TimeSpan.Zero)
|
// (which may be the case if there's only one message in the channel)
|
||||||
{
|
else
|
||||||
progress.Report(exportedDuration / totalDuration);
|
{
|
||||||
}
|
progress.Report(1);
|
||||||
// Avoid division by zero if all messages have the exact same timestamp
|
|
||||||
// (which may be the case if there's only one message in the channel)
|
|
||||||
else
|
|
||||||
{
|
|
||||||
progress.Report(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return message;
|
|
||||||
currentAfter = message.Id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
yield return message;
|
||||||
|
currentAfter = message.Id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,54 +3,53 @@ using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
public readonly partial record struct Snowflake(ulong Value)
|
||||||
{
|
{
|
||||||
public readonly partial record struct Snowflake(ulong Value)
|
public DateTimeOffset ToDate() => DateTimeOffset.FromUnixTimeMilliseconds(
|
||||||
|
(long)((Value >> 22) + 1420070400000UL)
|
||||||
|
).ToLocalTime();
|
||||||
|
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
|
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record struct Snowflake
|
||||||
|
{
|
||||||
|
public static Snowflake Zero { get; } = new(0);
|
||||||
|
|
||||||
|
public static Snowflake FromDate(DateTimeOffset date) => new(
|
||||||
|
((ulong)date.ToUnixTimeMilliseconds() - 1420070400000UL) << 22
|
||||||
|
);
|
||||||
|
|
||||||
|
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
|
||||||
{
|
{
|
||||||
public DateTimeOffset ToDate() => DateTimeOffset.FromUnixTimeMilliseconds(
|
if (string.IsNullOrWhiteSpace(str))
|
||||||
(long)((Value >> 22) + 1420070400000UL)
|
|
||||||
).ToLocalTime();
|
|
||||||
|
|
||||||
[ExcludeFromCodeCoverage]
|
|
||||||
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial record struct Snowflake
|
|
||||||
{
|
|
||||||
public static Snowflake Zero { get; } = new(0);
|
|
||||||
|
|
||||||
public static Snowflake FromDate(DateTimeOffset date) => new(
|
|
||||||
((ulong)date.ToUnixTimeMilliseconds() - 1420070400000UL) << 22
|
|
||||||
);
|
|
||||||
|
|
||||||
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(str))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// As number
|
|
||||||
if (Regex.IsMatch(str, @"^\d+$") && ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value))
|
|
||||||
{
|
|
||||||
return new Snowflake(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// As date
|
|
||||||
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var date))
|
|
||||||
{
|
|
||||||
return FromDate(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
// As number
|
||||||
|
if (Regex.IsMatch(str, @"^\d+$") && ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value))
|
||||||
|
{
|
||||||
|
return new Snowflake(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Snowflake Parse(string str, IFormatProvider? formatProvider) =>
|
// As date
|
||||||
TryParse(str, formatProvider) ?? throw new FormatException($"Invalid snowflake '{str}'.");
|
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var date))
|
||||||
|
{
|
||||||
|
return FromDate(date);
|
||||||
|
}
|
||||||
|
|
||||||
public static Snowflake Parse(string str) => Parse(str, null);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record struct Snowflake : IComparable<Snowflake>
|
public static Snowflake Parse(string str, IFormatProvider? formatProvider) =>
|
||||||
{
|
TryParse(str, formatProvider) ?? throw new FormatException($"Invalid snowflake '{str}'.");
|
||||||
public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
|
|
||||||
}
|
public static Snowflake Parse(string str) => Parse(str, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record struct Snowflake : IComparable<Snowflake>
|
||||||
|
{
|
||||||
|
public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
|
||||||
}
|
}
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exceptions
|
namespace DiscordChatExporter.Core.Exceptions;
|
||||||
|
|
||||||
|
public partial class DiscordChatExporterException : Exception
|
||||||
{
|
{
|
||||||
public partial class DiscordChatExporterException : Exception
|
public bool IsFatal { get; }
|
||||||
{
|
|
||||||
public bool IsFatal { get; }
|
|
||||||
|
|
||||||
public DiscordChatExporterException(string message, bool isFatal = false)
|
public DiscordChatExporterException(string message, bool isFatal = false)
|
||||||
: base(message)
|
: base(message)
|
||||||
{
|
{
|
||||||
IsFatal = isFatal;
|
IsFatal = isFatal;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public partial class DiscordChatExporterException
|
public partial class DiscordChatExporterException
|
||||||
|
{
|
||||||
|
internal static DiscordChatExporterException FailedHttpRequest(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
internal static DiscordChatExporterException FailedHttpRequest(HttpResponseMessage response)
|
var message = $@"
|
||||||
{
|
|
||||||
var message = $@"
|
|
||||||
Failed to perform an HTTP request.
|
Failed to perform an HTTP request.
|
||||||
|
|
||||||
[Request]
|
[Request]
|
||||||
|
|
@ -27,19 +27,18 @@ Failed to perform an HTTP request.
|
||||||
[Response]
|
[Response]
|
||||||
{response}";
|
{response}";
|
||||||
|
|
||||||
return new DiscordChatExporterException(message.Trim(), true);
|
return new DiscordChatExporterException(message.Trim(), true);
|
||||||
}
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException Unauthorized() =>
|
|
||||||
new("Authentication token is invalid.", true);
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException Forbidden() =>
|
|
||||||
new("Access is forbidden.");
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException NotFound(string resourceId) =>
|
|
||||||
new($"Requested resource ({resourceId}) does not exist.");
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException ChannelIsEmpty() =>
|
|
||||||
new("No messages found for the specified period.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static DiscordChatExporterException Unauthorized() =>
|
||||||
|
new("Authentication token is invalid.", true);
|
||||||
|
|
||||||
|
internal static DiscordChatExporterException Forbidden() =>
|
||||||
|
new("Access is forbidden.");
|
||||||
|
|
||||||
|
internal static DiscordChatExporterException NotFound(string resourceId) =>
|
||||||
|
new($"Requested resource ({resourceId}) does not exist.");
|
||||||
|
|
||||||
|
internal static DiscordChatExporterException ChannelIsEmpty() =>
|
||||||
|
new("No messages found for the specified period.");
|
||||||
}
|
}
|
||||||
|
|
@ -9,75 +9,74 @@ using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
|
public class ChannelExporter
|
||||||
{
|
{
|
||||||
public class ChannelExporter
|
private readonly DiscordClient _discord;
|
||||||
|
|
||||||
|
public ChannelExporter(DiscordClient discord) => _discord = discord;
|
||||||
|
|
||||||
|
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
|
||||||
|
|
||||||
|
public async ValueTask ExportChannelAsync(
|
||||||
|
ExportRequest request,
|
||||||
|
IProgress<double>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
private readonly DiscordClient _discord;
|
// Build context
|
||||||
|
var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
|
||||||
|
var contextChannels = await _discord.GetGuildChannelsAsync(request.Guild.Id, cancellationToken);
|
||||||
|
var contextRoles = await _discord.GetGuildRolesAsync(request.Guild.Id, cancellationToken);
|
||||||
|
|
||||||
public ChannelExporter(DiscordClient discord) => _discord = discord;
|
var context = new ExportContext(
|
||||||
|
request,
|
||||||
|
contextMembers,
|
||||||
|
contextChannels,
|
||||||
|
contextRoles
|
||||||
|
);
|
||||||
|
|
||||||
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
|
// Export messages
|
||||||
|
await using var messageExporter = new MessageExporter(context);
|
||||||
|
|
||||||
public async ValueTask ExportChannelAsync(
|
var exportedAnything = false;
|
||||||
ExportRequest request,
|
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
|
||||||
IProgress<double>? progress = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
await foreach (var message in _discord.GetMessagesAsync(
|
||||||
|
request.Channel.Id,
|
||||||
|
request.After,
|
||||||
|
request.Before,
|
||||||
|
progress,
|
||||||
|
cancellationToken))
|
||||||
{
|
{
|
||||||
// Build context
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
|
|
||||||
var contextChannels = await _discord.GetGuildChannelsAsync(request.Guild.Id, cancellationToken);
|
|
||||||
var contextRoles = await _discord.GetGuildRolesAsync(request.Guild.Id, cancellationToken);
|
|
||||||
|
|
||||||
var context = new ExportContext(
|
// Skips any messages that fail to pass the supplied filter
|
||||||
request,
|
if (!request.MessageFilter.IsMatch(message))
|
||||||
contextMembers,
|
continue;
|
||||||
contextChannels,
|
|
||||||
contextRoles
|
|
||||||
);
|
|
||||||
|
|
||||||
// Export messages
|
// Resolve members for referenced users
|
||||||
await using var messageExporter = new MessageExporter(context);
|
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
|
||||||
|
|
||||||
var exportedAnything = false;
|
|
||||||
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
|
|
||||||
|
|
||||||
await foreach (var message in _discord.GetMessagesAsync(
|
|
||||||
request.Channel.Id,
|
|
||||||
request.After,
|
|
||||||
request.Before,
|
|
||||||
progress,
|
|
||||||
cancellationToken))
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (!encounteredUsers.Add(referencedUser))
|
||||||
|
|
||||||
// Skips any messages that fail to pass the supplied filter
|
|
||||||
if (!request.MessageFilter.IsMatch(message))
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Resolve members for referenced users
|
var member = await _discord.GetGuildMemberAsync(
|
||||||
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
|
request.Guild.Id,
|
||||||
{
|
referencedUser,
|
||||||
if (!encounteredUsers.Add(referencedUser))
|
cancellationToken
|
||||||
continue;
|
);
|
||||||
|
|
||||||
var member = await _discord.GetGuildMemberAsync(
|
contextMembers.Add(member);
|
||||||
request.Guild.Id,
|
|
||||||
referencedUser,
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
contextMembers.Add(member);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export message
|
|
||||||
await messageExporter.ExportMessageAsync(message, cancellationToken);
|
|
||||||
exportedAnything = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw if no messages were exported
|
// Export message
|
||||||
if (!exportedAnything)
|
await messageExporter.ExportMessageAsync(message, cancellationToken);
|
||||||
throw DiscordChatExporterException.ChannelIsEmpty();
|
exportedAnything = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throw if no messages were exported
|
||||||
|
if (!exportedAnything)
|
||||||
|
throw DiscordChatExporterException.ChannelIsEmpty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -10,97 +10,96 @@ using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
|
internal class ExportContext
|
||||||
{
|
{
|
||||||
internal class ExportContext
|
private readonly MediaDownloader _mediaDownloader;
|
||||||
|
|
||||||
|
public ExportRequest Request { get; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<Member> Members { get; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<Channel> Channels { get; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<Role> Roles { get; }
|
||||||
|
|
||||||
|
public ExportContext(
|
||||||
|
ExportRequest request,
|
||||||
|
IReadOnlyCollection<Member> members,
|
||||||
|
IReadOnlyCollection<Channel> channels,
|
||||||
|
IReadOnlyCollection<Role> roles)
|
||||||
{
|
{
|
||||||
private readonly MediaDownloader _mediaDownloader;
|
Request = request;
|
||||||
|
Members = members;
|
||||||
|
Channels = channels;
|
||||||
|
Roles = roles;
|
||||||
|
|
||||||
public ExportRequest Request { get; }
|
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyCollection<Member> Members { get; }
|
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
||||||
|
{
|
||||||
|
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||||
|
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||||
|
var dateFormat => date.ToLocalString(dateFormat)
|
||||||
|
};
|
||||||
|
|
||||||
public IReadOnlyCollection<Channel> Channels { get; }
|
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
|
||||||
|
|
||||||
public IReadOnlyCollection<Role> Roles { get; }
|
public Channel? TryGetChannel(Snowflake id) => Channels.FirstOrDefault(c => c.Id == id);
|
||||||
|
|
||||||
public ExportContext(
|
public Role? TryGetRole(Snowflake id) => Roles.FirstOrDefault(r => r.Id == id);
|
||||||
ExportRequest request,
|
|
||||||
IReadOnlyCollection<Member> members,
|
public Color? TryGetUserColor(Snowflake id)
|
||||||
IReadOnlyCollection<Channel> channels,
|
{
|
||||||
IReadOnlyCollection<Role> roles)
|
var member = TryGetMember(id);
|
||||||
|
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
||||||
|
|
||||||
|
return roles?
|
||||||
|
.Where(r => r.Color is not null)
|
||||||
|
.OrderByDescending(r => r.Position)
|
||||||
|
.Select(r => r.Color)
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> ResolveMediaUrlAsync(string url, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!Request.ShouldDownloadMedia)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
Request = request;
|
var filePath = await _mediaDownloader.DownloadAsync(url, cancellationToken);
|
||||||
Members = members;
|
|
||||||
Channels = channels;
|
|
||||||
Roles = roles;
|
|
||||||
|
|
||||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
|
// We want relative path so that the output files can be copied around without breaking.
|
||||||
}
|
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
||||||
|
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath)
|
||||||
|
? Path.GetRelativePath(Request.OutputBaseDirPath, filePath)
|
||||||
|
: filePath;
|
||||||
|
|
||||||
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
// HACK: for HTML, we need to format the URL properly
|
||||||
{
|
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
||||||
"unix" => date.ToUnixTimeSeconds().ToString(),
|
|
||||||
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
|
||||||
var dateFormat => date.ToLocalString(dateFormat)
|
|
||||||
};
|
|
||||||
|
|
||||||
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
|
|
||||||
|
|
||||||
public Channel? TryGetChannel(Snowflake id) => Channels.FirstOrDefault(c => c.Id == id);
|
|
||||||
|
|
||||||
public Role? TryGetRole(Snowflake id) => Roles.FirstOrDefault(r => r.Id == id);
|
|
||||||
|
|
||||||
public Color? TryGetUserColor(Snowflake id)
|
|
||||||
{
|
|
||||||
var member = TryGetMember(id);
|
|
||||||
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
|
||||||
|
|
||||||
return roles?
|
|
||||||
.Where(r => r.Color is not null)
|
|
||||||
.OrderByDescending(r => r.Position)
|
|
||||||
.Select(r => r.Color)
|
|
||||||
.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ResolveMediaUrlAsync(string url, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!Request.ShouldDownloadMedia)
|
|
||||||
return url;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var filePath = await _mediaDownloader.DownloadAsync(url, cancellationToken);
|
// Need to escape each path segment while keeping the directory separators intact
|
||||||
|
return string.Join(
|
||||||
// We want relative path so that the output files can be copied around without breaking.
|
Path.AltDirectorySeparatorChar,
|
||||||
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
relativeFilePath
|
||||||
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath)
|
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||||
? Path.GetRelativePath(Request.OutputBaseDirPath, filePath)
|
.Select(Uri.EscapeDataString)
|
||||||
: filePath;
|
);
|
||||||
|
|
||||||
// HACK: for HTML, we need to format the URL properly
|
|
||||||
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
|
||||||
{
|
|
||||||
// Need to escape each path segment while keeping the directory separators intact
|
|
||||||
return string.Join(
|
|
||||||
Path.AltDirectorySeparatorChar,
|
|
||||||
relativeFilePath
|
|
||||||
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
|
||||||
.Select(Uri.EscapeDataString)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return relativeFilePath;
|
|
||||||
}
|
|
||||||
// Try to catch only exceptions related to failed HTTP requests
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
|
||||||
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
|
||||||
{
|
|
||||||
// TODO: add logging so we can be more liberal with catching exceptions
|
|
||||||
// We don't want this to crash the exporting process in case of failure
|
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return relativeFilePath;
|
||||||
|
}
|
||||||
|
// Try to catch only exceptions related to failed HTTP requests
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
||||||
|
{
|
||||||
|
// TODO: add logging so we can be more liberal with catching exceptions
|
||||||
|
// We don't want this to crash the exporting process in case of failure
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,36 +1,35 @@
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
|
public enum ExportFormat
|
||||||
{
|
{
|
||||||
public enum ExportFormat
|
PlainText,
|
||||||
{
|
HtmlDark,
|
||||||
PlainText,
|
HtmlLight,
|
||||||
HtmlDark,
|
Csv,
|
||||||
HtmlLight,
|
Json
|
||||||
Csv,
|
}
|
||||||
Json
|
|
||||||
}
|
public static class ExportFormatExtensions
|
||||||
|
{
|
||||||
public static class ExportFormatExtensions
|
public static string GetFileExtension(this ExportFormat format) => format switch
|
||||||
{
|
{
|
||||||
public static string GetFileExtension(this ExportFormat format) => format switch
|
ExportFormat.PlainText => "txt",
|
||||||
{
|
ExportFormat.HtmlDark => "html",
|
||||||
ExportFormat.PlainText => "txt",
|
ExportFormat.HtmlLight => "html",
|
||||||
ExportFormat.HtmlDark => "html",
|
ExportFormat.Csv => "csv",
|
||||||
ExportFormat.HtmlLight => "html",
|
ExportFormat.Json => "json",
|
||||||
ExportFormat.Csv => "csv",
|
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||||
ExportFormat.Json => "json",
|
};
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
|
||||||
};
|
public static string GetDisplayName(this ExportFormat format) => format switch
|
||||||
|
{
|
||||||
public static string GetDisplayName(this ExportFormat format) => format switch
|
ExportFormat.PlainText => "TXT",
|
||||||
{
|
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||||
ExportFormat.PlainText => "TXT",
|
ExportFormat.HtmlLight => "HTML (Light)",
|
||||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
ExportFormat.Csv => "CSV",
|
||||||
ExportFormat.HtmlLight => "HTML (Light)",
|
ExportFormat.Json => "JSON",
|
||||||
ExportFormat.Csv => "CSV",
|
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||||
ExportFormat.Json => "JSON",
|
};
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -8,120 +8,119 @@ using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
|
public partial record ExportRequest(
|
||||||
|
Guild Guild,
|
||||||
|
Channel Channel,
|
||||||
|
string OutputPath,
|
||||||
|
ExportFormat Format,
|
||||||
|
Snowflake? After,
|
||||||
|
Snowflake? Before,
|
||||||
|
PartitionLimit PartitionLimit,
|
||||||
|
MessageFilter MessageFilter,
|
||||||
|
bool ShouldDownloadMedia,
|
||||||
|
bool ShouldReuseMedia,
|
||||||
|
string DateFormat)
|
||||||
{
|
{
|
||||||
public partial record ExportRequest(
|
private string? _outputBaseFilePath;
|
||||||
Guild Guild,
|
public string OutputBaseFilePath => _outputBaseFilePath ??= GetOutputBaseFilePath(
|
||||||
Channel Channel,
|
Guild,
|
||||||
string OutputPath,
|
Channel,
|
||||||
ExportFormat Format,
|
OutputPath,
|
||||||
Snowflake? After,
|
Format,
|
||||||
Snowflake? Before,
|
After,
|
||||||
PartitionLimit PartitionLimit,
|
Before
|
||||||
MessageFilter MessageFilter,
|
);
|
||||||
bool ShouldDownloadMedia,
|
|
||||||
bool ShouldReuseMedia,
|
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
|
||||||
string DateFormat)
|
|
||||||
|
public string OutputMediaDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial record ExportRequest
|
||||||
|
{
|
||||||
|
private static string GetOutputBaseFilePath(
|
||||||
|
Guild guild,
|
||||||
|
Channel channel,
|
||||||
|
string outputPath,
|
||||||
|
ExportFormat format,
|
||||||
|
Snowflake? after = null,
|
||||||
|
Snowflake? before = null)
|
||||||
{
|
{
|
||||||
private string? _outputBaseFilePath;
|
|
||||||
public string OutputBaseFilePath => _outputBaseFilePath ??= GetOutputBaseFilePath(
|
// Formats path
|
||||||
Guild,
|
outputPath = Regex.Replace(outputPath, "%.", m =>
|
||||||
Channel,
|
PathEx.EscapePath(m.Value switch
|
||||||
OutputPath,
|
{
|
||||||
Format,
|
"%g" => guild.Id.ToString(),
|
||||||
After,
|
"%G" => guild.Name,
|
||||||
Before
|
"%t" => channel.Category.Id.ToString(),
|
||||||
|
"%T" => channel.Category.Name,
|
||||||
|
"%c" => channel.Id.ToString(),
|
||||||
|
"%C" => channel.Name,
|
||||||
|
"%p" => channel.Position?.ToString() ?? "0",
|
||||||
|
"%P" => channel.Category.Position?.ToString() ?? "0",
|
||||||
|
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
|
||||||
|
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
|
||||||
|
"%%" => "%",
|
||||||
|
_ => m.Value
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
|
// Output is a directory
|
||||||
|
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
|
||||||
|
{
|
||||||
|
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
|
||||||
|
return Path.Combine(outputPath, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
public string OutputMediaDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
|
// Output is a file
|
||||||
|
return outputPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record ExportRequest
|
public static string GetDefaultOutputFileName(
|
||||||
|
Guild guild,
|
||||||
|
Channel channel,
|
||||||
|
ExportFormat format,
|
||||||
|
Snowflake? after = null,
|
||||||
|
Snowflake? before = null)
|
||||||
{
|
{
|
||||||
private static string GetOutputBaseFilePath(
|
var buffer = new StringBuilder();
|
||||||
Guild guild,
|
|
||||||
Channel channel,
|
// Guild and channel names
|
||||||
string outputPath,
|
buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]");
|
||||||
ExportFormat format,
|
|
||||||
Snowflake? after = null,
|
// Date range
|
||||||
Snowflake? before = null)
|
if (after is not null || before is not null)
|
||||||
{
|
{
|
||||||
|
buffer.Append(" (");
|
||||||
|
|
||||||
// Formats path
|
// Both 'after' and 'before' are set
|
||||||
outputPath = Regex.Replace(outputPath, "%.", m =>
|
if (after is not null && before is not null)
|
||||||
PathEx.EscapePath(m.Value switch
|
|
||||||
{
|
|
||||||
"%g" => guild.Id.ToString(),
|
|
||||||
"%G" => guild.Name,
|
|
||||||
"%t" => channel.Category.Id.ToString(),
|
|
||||||
"%T" => channel.Category.Name,
|
|
||||||
"%c" => channel.Id.ToString(),
|
|
||||||
"%C" => channel.Name,
|
|
||||||
"%p" => channel.Position?.ToString() ?? "0",
|
|
||||||
"%P" => channel.Category.Position?.ToString() ?? "0",
|
|
||||||
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
|
|
||||||
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
|
|
||||||
"%%" => "%",
|
|
||||||
_ => m.Value
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Output is a directory
|
|
||||||
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
|
|
||||||
{
|
{
|
||||||
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
|
buffer.Append($"{after.Value.ToDate():yyyy-MM-dd} to {before.Value.ToDate():yyyy-MM-dd}");
|
||||||
return Path.Combine(outputPath, fileName);
|
}
|
||||||
|
// Only 'after' is set
|
||||||
|
else if (after is not null)
|
||||||
|
{
|
||||||
|
buffer.Append($"after {after.Value.ToDate():yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
// Only 'before' is set
|
||||||
|
else if (before is not null)
|
||||||
|
{
|
||||||
|
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output is a file
|
buffer.Append(")");
|
||||||
return outputPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GetDefaultOutputFileName(
|
// File extension
|
||||||
Guild guild,
|
buffer.Append($".{format.GetFileExtension()}");
|
||||||
Channel channel,
|
|
||||||
ExportFormat format,
|
|
||||||
Snowflake? after = null,
|
|
||||||
Snowflake? before = null)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
// Guild and channel names
|
// Replace invalid chars
|
||||||
buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]");
|
PathEx.EscapePath(buffer);
|
||||||
|
|
||||||
// Date range
|
return buffer.ToString();
|
||||||
if (after is not null || before is not null)
|
|
||||||
{
|
|
||||||
buffer.Append(" (");
|
|
||||||
|
|
||||||
// Both 'after' and 'before' are set
|
|
||||||
if (after is not null && before is not null)
|
|
||||||
{
|
|
||||||
buffer.Append($"{after.Value.ToDate():yyyy-MM-dd} to {before.Value.ToDate():yyyy-MM-dd}");
|
|
||||||
}
|
|
||||||
// Only 'after' is set
|
|
||||||
else if (after is not null)
|
|
||||||
{
|
|
||||||
buffer.Append($"after {after.Value.ToDate():yyyy-MM-dd}");
|
|
||||||
}
|
|
||||||
// Only 'before' is set
|
|
||||||
else if (before is not null)
|
|
||||||
{
|
|
||||||
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.Append(")");
|
|
||||||
}
|
|
||||||
|
|
||||||
// File extension
|
|
||||||
buffer.Append($".{format.GetFileExtension()}");
|
|
||||||
|
|
||||||
// Replace invalid chars
|
|
||||||
PathEx.EscapePath(buffer);
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal enum BinaryExpressionKind
|
||||||
{
|
{
|
||||||
internal enum BinaryExpressionKind
|
Or,
|
||||||
{
|
And
|
||||||
Or,
|
|
||||||
And
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,26 +1,25 @@
|
||||||
using System;
|
using System;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class BinaryExpressionMessageFilter : MessageFilter
|
||||||
{
|
{
|
||||||
internal class BinaryExpressionMessageFilter : MessageFilter
|
private readonly MessageFilter _first;
|
||||||
|
private readonly MessageFilter _second;
|
||||||
|
private readonly BinaryExpressionKind _kind;
|
||||||
|
|
||||||
|
public BinaryExpressionMessageFilter(MessageFilter first, MessageFilter second, BinaryExpressionKind kind)
|
||||||
{
|
{
|
||||||
private readonly MessageFilter _first;
|
_first = first;
|
||||||
private readonly MessageFilter _second;
|
_second = second;
|
||||||
private readonly BinaryExpressionKind _kind;
|
_kind = kind;
|
||||||
|
|
||||||
public BinaryExpressionMessageFilter(MessageFilter first, MessageFilter second, BinaryExpressionKind kind)
|
|
||||||
{
|
|
||||||
_first = first;
|
|
||||||
_second = second;
|
|
||||||
_kind = kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override bool IsMatch(Message message) => _kind switch
|
|
||||||
{
|
|
||||||
BinaryExpressionKind.Or => _first.IsMatch(message) || _second.IsMatch(message),
|
|
||||||
BinaryExpressionKind.And => _first.IsMatch(message) && _second.IsMatch(message),
|
|
||||||
_ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.")
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override bool IsMatch(Message message) => _kind switch
|
||||||
|
{
|
||||||
|
BinaryExpressionKind.Or => _first.IsMatch(message) || _second.IsMatch(message),
|
||||||
|
BinaryExpressionKind.And => _first.IsMatch(message) && _second.IsMatch(message),
|
||||||
|
_ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -2,33 +2,32 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class ContainsMessageFilter : MessageFilter
|
||||||
{
|
{
|
||||||
internal class ContainsMessageFilter : MessageFilter
|
private readonly string _text;
|
||||||
{
|
|
||||||
private readonly string _text;
|
|
||||||
|
|
||||||
public ContainsMessageFilter(string text) => _text = text;
|
public ContainsMessageFilter(string text) => _text = text;
|
||||||
|
|
||||||
private bool IsMatch(string? content) =>
|
private bool IsMatch(string? content) =>
|
||||||
!string.IsNullOrWhiteSpace(content) &&
|
!string.IsNullOrWhiteSpace(content) &&
|
||||||
Regex.IsMatch(
|
Regex.IsMatch(
|
||||||
content,
|
content,
|
||||||
"\\b" + Regex.Escape(_text) + "\\b",
|
"\\b" + Regex.Escape(_text) + "\\b",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
|
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
|
||||||
);
|
);
|
||||||
|
|
||||||
public override bool IsMatch(Message message) =>
|
public override bool IsMatch(Message message) =>
|
||||||
IsMatch(message.Content) ||
|
IsMatch(message.Content) ||
|
||||||
message.Embeds.Any(e =>
|
message.Embeds.Any(e =>
|
||||||
IsMatch(e.Title) ||
|
IsMatch(e.Title) ||
|
||||||
IsMatch(e.Author?.Name) ||
|
IsMatch(e.Author?.Name) ||
|
||||||
IsMatch(e.Description) ||
|
IsMatch(e.Description) ||
|
||||||
IsMatch(e.Footer?.Text) ||
|
IsMatch(e.Footer?.Text) ||
|
||||||
e.Fields.Any(f =>
|
e.Fields.Any(f =>
|
||||||
IsMatch(f.Name) ||
|
IsMatch(f.Name) ||
|
||||||
IsMatch(f.Value)
|
IsMatch(f.Value)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
using System;
|
using System;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class FromMessageFilter : MessageFilter
|
||||||
{
|
{
|
||||||
internal class FromMessageFilter : MessageFilter
|
private readonly string _value;
|
||||||
{
|
|
||||||
private readonly string _value;
|
|
||||||
|
|
||||||
public FromMessageFilter(string value) => _value = value;
|
public FromMessageFilter(string value) => _value = value;
|
||||||
|
|
||||||
public override bool IsMatch(Message message) =>
|
public override bool IsMatch(Message message) =>
|
||||||
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
|
string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -3,23 +3,22 @@ using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class HasMessageFilter : MessageFilter
|
||||||
{
|
{
|
||||||
internal class HasMessageFilter : MessageFilter
|
private readonly MessageContentMatchKind _kind;
|
||||||
|
|
||||||
|
public HasMessageFilter(MessageContentMatchKind kind) => _kind = kind;
|
||||||
|
|
||||||
|
public override bool IsMatch(Message message) => _kind switch
|
||||||
{
|
{
|
||||||
private readonly MessageContentMatchKind _kind;
|
MessageContentMatchKind.Link => Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
|
||||||
|
MessageContentMatchKind.Embed => message.Embeds.Any(),
|
||||||
public HasMessageFilter(MessageContentMatchKind kind) => _kind = kind;
|
MessageContentMatchKind.File => message.Attachments.Any(),
|
||||||
|
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
||||||
public override bool IsMatch(Message message) => _kind switch
|
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
||||||
{
|
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||||
MessageContentMatchKind.Link => Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
|
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
|
||||||
MessageContentMatchKind.Embed => message.Embeds.Any(),
|
};
|
||||||
MessageContentMatchKind.File => message.Attachments.Any(),
|
|
||||||
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
|
||||||
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
|
||||||
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
|
||||||
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,18 +2,17 @@
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class MentionsMessageFilter : MessageFilter
|
||||||
{
|
{
|
||||||
internal class MentionsMessageFilter : MessageFilter
|
private readonly string _value;
|
||||||
{
|
|
||||||
private readonly string _value;
|
|
||||||
|
|
||||||
public MentionsMessageFilter(string value) => _value = value;
|
public MentionsMessageFilter(string value) => _value = value;
|
||||||
|
|
||||||
public override bool IsMatch(Message message) => message.MentionedUsers.Any(user =>
|
public override bool IsMatch(Message message) => message.MentionedUsers.Any(user =>
|
||||||
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) ||
|
string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) ||
|
||||||
string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal enum MessageContentMatchKind
|
||||||
{
|
{
|
||||||
internal enum MessageContentMatchKind
|
Link,
|
||||||
{
|
Embed,
|
||||||
Link,
|
File,
|
||||||
Embed,
|
Video,
|
||||||
File,
|
Image,
|
||||||
Video,
|
Sound
|
||||||
Image,
|
|
||||||
Sound
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,17 +2,16 @@
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering.Parsing;
|
using DiscordChatExporter.Core.Exporting.Filtering.Parsing;
|
||||||
using Superpower;
|
using Superpower;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
public abstract partial class MessageFilter
|
||||||
{
|
{
|
||||||
public abstract partial class MessageFilter
|
public abstract bool IsMatch(Message message);
|
||||||
{
|
}
|
||||||
public abstract bool IsMatch(Message message);
|
|
||||||
}
|
public partial class MessageFilter
|
||||||
|
{
|
||||||
public partial class MessageFilter
|
public static MessageFilter Null { get; } = new NullMessageFilter();
|
||||||
{
|
|
||||||
public static MessageFilter Null { get; } = new NullMessageFilter();
|
public static MessageFilter Parse(string value) => FilterGrammar.Filter.Parse(value);
|
||||||
|
|
||||||
public static MessageFilter Parse(string value) => FilterGrammar.Filter.Parse(value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class NegatedMessageFilter : MessageFilter
|
||||||
{
|
{
|
||||||
internal class NegatedMessageFilter : MessageFilter
|
private readonly MessageFilter _filter;
|
||||||
{
|
|
||||||
private readonly MessageFilter _filter;
|
|
||||||
|
|
||||||
public NegatedMessageFilter(MessageFilter filter) => _filter = filter;
|
public NegatedMessageFilter(MessageFilter filter) => _filter = filter;
|
||||||
|
|
||||||
public override bool IsMatch(Message message) => !_filter.IsMatch(message);
|
public override bool IsMatch(Message message) => !_filter.IsMatch(message);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class NullMessageFilter : MessageFilter
|
||||||
{
|
{
|
||||||
internal class NullMessageFilter : MessageFilter
|
public override bool IsMatch(Message message) => true;
|
||||||
{
|
|
||||||
public override bool IsMatch(Message message) => true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,101 +2,100 @@
|
||||||
using Superpower;
|
using Superpower;
|
||||||
using Superpower.Parsers;
|
using Superpower.Parsers;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Filtering.Parsing
|
namespace DiscordChatExporter.Core.Exporting.Filtering.Parsing;
|
||||||
|
|
||||||
|
internal static class FilterGrammar
|
||||||
{
|
{
|
||||||
internal static class FilterGrammar
|
private static readonly TextParser<char> EscapedCharacter =
|
||||||
{
|
Character.EqualTo('\\').IgnoreThen(Character.AnyChar);
|
||||||
private static readonly TextParser<char> EscapedCharacter =
|
|
||||||
Character.EqualTo('\\').IgnoreThen(Character.AnyChar);
|
|
||||||
|
|
||||||
private static readonly TextParser<string> QuotedString =
|
private static readonly TextParser<string> QuotedString =
|
||||||
from open in Character.In('"', '\'')
|
from open in Character.In('"', '\'')
|
||||||
from value in Parse.OneOf(EscapedCharacter, Character.Except(open)).Many().Text()
|
from value in Parse.OneOf(EscapedCharacter, Character.Except(open)).Many().Text()
|
||||||
from close in Character.EqualTo(open)
|
from close in Character.EqualTo(open)
|
||||||
select value;
|
select value;
|
||||||
|
|
||||||
private static readonly TextParser<char> FreeCharacter =
|
private static readonly TextParser<char> FreeCharacter =
|
||||||
Character.Matching(c =>
|
Character.Matching(c =>
|
||||||
!char.IsWhiteSpace(c) &&
|
!char.IsWhiteSpace(c) &&
|
||||||
// Avoid all special tokens used by the grammar
|
// Avoid all special tokens used by the grammar
|
||||||
c is not ('(' or ')' or '"' or '\'' or '-' or '|' or '&'),
|
c is not ('(' or ')' or '"' or '\'' or '-' or '|' or '&'),
|
||||||
"any character except whitespace or `(`, `)`, `\"`, `'`, `-`, `|`, `&`"
|
"any character except whitespace or `(`, `)`, `\"`, `'`, `-`, `|`, `&`"
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly TextParser<string> UnquotedString =
|
|
||||||
Parse.OneOf(EscapedCharacter, FreeCharacter).AtLeastOnce().Text();
|
|
||||||
|
|
||||||
private static readonly TextParser<string> String =
|
|
||||||
Parse.OneOf(QuotedString, UnquotedString).Named("text string");
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> ContainsFilter =
|
|
||||||
String.Select(v => (MessageFilter) new ContainsMessageFilter(v));
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> FromFilter = Span
|
|
||||||
.EqualToIgnoreCase("from:")
|
|
||||||
.IgnoreThen(String)
|
|
||||||
.Select(v => (MessageFilter) new FromMessageFilter(v))
|
|
||||||
.Named("from:<value>");
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> MentionsFilter = Span
|
|
||||||
.EqualToIgnoreCase("mentions:")
|
|
||||||
.IgnoreThen(String)
|
|
||||||
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
|
|
||||||
.Named("mentions:<value>");
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> HasFilter = Span
|
|
||||||
.EqualToIgnoreCase("has:")
|
|
||||||
.IgnoreThen(Parse.OneOf(
|
|
||||||
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
|
||||||
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
|
||||||
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
|
||||||
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
|
||||||
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
|
||||||
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound))
|
|
||||||
))
|
|
||||||
.Select(k => (MessageFilter) new HasMessageFilter(k))
|
|
||||||
.Named("has:<value>");
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> NegatedFilter = Character
|
|
||||||
.EqualTo('-')
|
|
||||||
.IgnoreThen(Parse.Ref(() => StandaloneFilter!))
|
|
||||||
.Select(f => (MessageFilter) new NegatedMessageFilter(f));
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> GroupedFilter =
|
|
||||||
from open in Character.EqualTo('(')
|
|
||||||
from content in Parse.Ref(() => BinaryExpressionFilter!).Token()
|
|
||||||
from close in Character.EqualTo(')')
|
|
||||||
select content;
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> StandaloneFilter = Parse.OneOf(
|
|
||||||
GroupedFilter,
|
|
||||||
FromFilter,
|
|
||||||
MentionsFilter,
|
|
||||||
HasFilter,
|
|
||||||
ContainsFilter
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> UnaryExpressionFilter = Parse.OneOf(
|
private static readonly TextParser<string> UnquotedString =
|
||||||
NegatedFilter,
|
Parse.OneOf(EscapedCharacter, FreeCharacter).AtLeastOnce().Text();
|
||||||
StandaloneFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> BinaryExpressionFilter = Parse.Chain(
|
private static readonly TextParser<string> String =
|
||||||
Parse.OneOf(
|
Parse.OneOf(QuotedString, UnquotedString).Named("text string");
|
||||||
// Explicit operator
|
|
||||||
Character.In('|', '&').Token().Try(),
|
|
||||||
// Implicit operator (resolves to 'and')
|
|
||||||
Character.WhiteSpace.AtLeastOnce().IgnoreThen(Parse.Return(' '))
|
|
||||||
),
|
|
||||||
UnaryExpressionFilter,
|
|
||||||
(op, left, right) => op switch
|
|
||||||
{
|
|
||||||
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
|
||||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
public static readonly TextParser<MessageFilter> Filter =
|
private static readonly TextParser<MessageFilter> ContainsFilter =
|
||||||
BinaryExpressionFilter.Token().AtEnd();
|
String.Select(v => (MessageFilter) new ContainsMessageFilter(v));
|
||||||
}
|
|
||||||
|
private static readonly TextParser<MessageFilter> FromFilter = Span
|
||||||
|
.EqualToIgnoreCase("from:")
|
||||||
|
.IgnoreThen(String)
|
||||||
|
.Select(v => (MessageFilter) new FromMessageFilter(v))
|
||||||
|
.Named("from:<value>");
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> MentionsFilter = Span
|
||||||
|
.EqualToIgnoreCase("mentions:")
|
||||||
|
.IgnoreThen(String)
|
||||||
|
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
|
||||||
|
.Named("mentions:<value>");
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> HasFilter = Span
|
||||||
|
.EqualToIgnoreCase("has:")
|
||||||
|
.IgnoreThen(Parse.OneOf(
|
||||||
|
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
||||||
|
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
||||||
|
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||||
|
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||||
|
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||||
|
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound))
|
||||||
|
))
|
||||||
|
.Select(k => (MessageFilter) new HasMessageFilter(k))
|
||||||
|
.Named("has:<value>");
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> NegatedFilter = Character
|
||||||
|
.EqualTo('-')
|
||||||
|
.IgnoreThen(Parse.Ref(() => StandaloneFilter!))
|
||||||
|
.Select(f => (MessageFilter) new NegatedMessageFilter(f));
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> GroupedFilter =
|
||||||
|
from open in Character.EqualTo('(')
|
||||||
|
from content in Parse.Ref(() => BinaryExpressionFilter!).Token()
|
||||||
|
from close in Character.EqualTo(')')
|
||||||
|
select content;
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> StandaloneFilter = Parse.OneOf(
|
||||||
|
GroupedFilter,
|
||||||
|
FromFilter,
|
||||||
|
MentionsFilter,
|
||||||
|
HasFilter,
|
||||||
|
ContainsFilter
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> UnaryExpressionFilter = Parse.OneOf(
|
||||||
|
NegatedFilter,
|
||||||
|
StandaloneFilter
|
||||||
|
);
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> BinaryExpressionFilter = Parse.Chain(
|
||||||
|
Parse.OneOf(
|
||||||
|
// Explicit operator
|
||||||
|
Character.In('|', '&').Token().Try(),
|
||||||
|
// Implicit operator (resolves to 'and')
|
||||||
|
Character.WhiteSpace.AtLeastOnce().IgnoreThen(Parse.Return(' '))
|
||||||
|
),
|
||||||
|
UnaryExpressionFilter,
|
||||||
|
(op, left, right) => op switch
|
||||||
|
{
|
||||||
|
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
||||||
|
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly TextParser<MessageFilter> Filter =
|
||||||
|
BinaryExpressionFilter.Token().AtEnd();
|
||||||
}
|
}
|
||||||
|
|
@ -10,101 +10,100 @@ using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
|
internal partial class MediaDownloader
|
||||||
{
|
{
|
||||||
internal partial class MediaDownloader
|
private readonly string _workingDirPath;
|
||||||
|
private readonly bool _reuseMedia;
|
||||||
|
|
||||||
|
// File paths of already downloaded media
|
||||||
|
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
public MediaDownloader(string workingDirPath, bool reuseMedia)
|
||||||
{
|
{
|
||||||
private readonly string _workingDirPath;
|
_workingDirPath = workingDirPath;
|
||||||
private readonly bool _reuseMedia;
|
_reuseMedia = reuseMedia;
|
||||||
|
|
||||||
// File paths of already downloaded media
|
|
||||||
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
public MediaDownloader(string workingDirPath, bool reuseMedia)
|
|
||||||
{
|
|
||||||
_workingDirPath = workingDirPath;
|
|
||||||
_reuseMedia = reuseMedia;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
|
||||||
return cachedFilePath;
|
|
||||||
|
|
||||||
var fileName = GetFileNameFromUrl(url);
|
|
||||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
|
||||||
|
|
||||||
// Reuse existing files if we're allowed to
|
|
||||||
if (_reuseMedia && File.Exists(filePath))
|
|
||||||
return _pathCache[url] = filePath;
|
|
||||||
|
|
||||||
Directory.CreateDirectory(_workingDirPath);
|
|
||||||
|
|
||||||
// This retries on IOExceptions which is dangerous as we're also working with files
|
|
||||||
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
// Download the file
|
|
||||||
using var response = await Http.Client.GetAsync(url, cancellationToken);
|
|
||||||
await using (var output = File.Create(filePath))
|
|
||||||
{
|
|
||||||
await response.Content.CopyToAsync(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to set the file date according to the last-modified header
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
|
||||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
|
||||||
? date
|
|
||||||
: (DateTimeOffset?) null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastModified is not null)
|
|
||||||
{
|
|
||||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// This can apparently fail for some reason.
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
|
||||||
// Updating file dates is not a critical task, so we'll just
|
|
||||||
// ignore exceptions thrown here.
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return _pathCache[url] = filePath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MediaDownloader
|
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
private static string GetUrlHash(string url)
|
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||||
|
return cachedFilePath;
|
||||||
|
|
||||||
|
var fileName = GetFileNameFromUrl(url);
|
||||||
|
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||||
|
|
||||||
|
// Reuse existing files if we're allowed to
|
||||||
|
if (_reuseMedia && File.Exists(filePath))
|
||||||
|
return _pathCache[url] = filePath;
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_workingDirPath);
|
||||||
|
|
||||||
|
// This retries on IOExceptions which is dangerous as we're also working with files
|
||||||
|
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
using var hash = SHA256.Create();
|
// Download the file
|
||||||
|
using var response = await Http.Client.GetAsync(url, cancellationToken);
|
||||||
|
await using (var output = File.Create(filePath))
|
||||||
|
{
|
||||||
|
await response.Content.CopyToAsync(output, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
// Try to set the file date according to the last-modified header
|
||||||
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
try
|
||||||
}
|
{
|
||||||
|
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||||
|
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||||
|
? date
|
||||||
|
: (DateTimeOffset?) null
|
||||||
|
);
|
||||||
|
|
||||||
private static string GetFileNameFromUrl(string url)
|
if (lastModified is not null)
|
||||||
{
|
{
|
||||||
var urlHash = GetUrlHash(url);
|
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// This can apparently fail for some reason.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||||
|
// Updating file dates is not a critical task, so we'll just
|
||||||
|
// ignore exceptions thrown here.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Try to extract file name from URL
|
return _pathCache[url] = filePath;
|
||||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
}
|
||||||
|
}
|
||||||
// If it's not there, just use the URL hash as the file name
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
internal partial class MediaDownloader
|
||||||
return urlHash;
|
{
|
||||||
|
private static string GetUrlHash(string url)
|
||||||
// Otherwise, use the original file name but inject the hash in the middle
|
{
|
||||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
using var hash = SHA256.Create();
|
||||||
var fileExtension = Path.GetExtension(fileName);
|
|
||||||
|
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
||||||
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetFileNameFromUrl(string url)
|
||||||
|
{
|
||||||
|
var urlHash = GetUrlHash(url);
|
||||||
|
|
||||||
|
// Try to extract file name from URL
|
||||||
|
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||||
|
|
||||||
|
// If it's not there, just use the URL hash as the file name
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
return urlHash;
|
||||||
|
|
||||||
|
// Otherwise, use the original file name but inject the hash in the middle
|
||||||
|
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
var fileExtension = Path.GetExtension(fileName);
|
||||||
|
|
||||||
|
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,101 +5,100 @@ using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Writers;
|
using DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
|
internal partial class MessageExporter : IAsyncDisposable
|
||||||
{
|
{
|
||||||
internal partial class MessageExporter : IAsyncDisposable
|
private readonly ExportContext _context;
|
||||||
|
|
||||||
|
private int _partitionIndex;
|
||||||
|
private MessageWriter? _writer;
|
||||||
|
|
||||||
|
public MessageExporter(ExportContext context)
|
||||||
{
|
{
|
||||||
private readonly ExportContext _context;
|
_context = context;
|
||||||
|
|
||||||
private int _partitionIndex;
|
|
||||||
private MessageWriter? _writer;
|
|
||||||
|
|
||||||
public MessageExporter(ExportContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask ResetWriterAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (_writer is not null)
|
|
||||||
{
|
|
||||||
await _writer.WritePostambleAsync(cancellationToken);
|
|
||||||
await _writer.DisposeAsync();
|
|
||||||
_writer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<MessageWriter> GetWriterAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Ensure partition limit has not been reached
|
|
||||||
if (_writer is not null &&
|
|
||||||
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten))
|
|
||||||
{
|
|
||||||
await ResetWriterAsync(cancellationToken);
|
|
||||||
_partitionIndex++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Writer is still valid - return
|
|
||||||
if (_writer is not null)
|
|
||||||
return _writer;
|
|
||||||
|
|
||||||
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex);
|
|
||||||
|
|
||||||
var dirPath = Path.GetDirectoryName(_context.Request.OutputBaseFilePath);
|
|
||||||
if (!string.IsNullOrWhiteSpace(dirPath))
|
|
||||||
Directory.CreateDirectory(dirPath);
|
|
||||||
|
|
||||||
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context);
|
|
||||||
await writer.WritePreambleAsync(cancellationToken);
|
|
||||||
|
|
||||||
return _writer = writer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask ExportMessageAsync(Message message, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var writer = await GetWriterAsync(cancellationToken);
|
|
||||||
await writer.WriteMessageAsync(message, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MessageExporter
|
private async ValueTask ResetWriterAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
|
if (_writer is not null)
|
||||||
{
|
{
|
||||||
// First partition - don't change file name
|
await _writer.WritePostambleAsync(cancellationToken);
|
||||||
if (partitionIndex <= 0)
|
await _writer.DisposeAsync();
|
||||||
return baseFilePath;
|
_writer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Inject partition index into file name
|
private async ValueTask<MessageWriter> GetWriterAsync(CancellationToken cancellationToken = default)
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
{
|
||||||
var fileExt = Path.GetExtension(baseFilePath);
|
// Ensure partition limit has not been reached
|
||||||
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
if (_writer is not null &&
|
||||||
var dirPath = Path.GetDirectoryName(baseFilePath);
|
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten))
|
||||||
|
{
|
||||||
return !string.IsNullOrWhiteSpace(dirPath)
|
await ResetWriterAsync(cancellationToken);
|
||||||
? Path.Combine(dirPath, fileName)
|
_partitionIndex++;
|
||||||
: fileName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageWriter CreateMessageWriter(
|
// Writer is still valid - return
|
||||||
string filePath,
|
if (_writer is not null)
|
||||||
ExportFormat format,
|
return _writer;
|
||||||
ExportContext context)
|
|
||||||
{
|
|
||||||
// Stream will be disposed by the underlying writer
|
|
||||||
var stream = File.Create(filePath);
|
|
||||||
|
|
||||||
return format switch
|
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex);
|
||||||
{
|
|
||||||
ExportFormat.PlainText => new PlainTextMessageWriter(stream, context),
|
var dirPath = Path.GetDirectoryName(_context.Request.OutputBaseFilePath);
|
||||||
ExportFormat.Csv => new CsvMessageWriter(stream, context),
|
if (!string.IsNullOrWhiteSpace(dirPath))
|
||||||
ExportFormat.HtmlDark => new HtmlMessageWriter(stream, context, "Dark"),
|
Directory.CreateDirectory(dirPath);
|
||||||
ExportFormat.HtmlLight => new HtmlMessageWriter(stream, context, "Light"),
|
|
||||||
ExportFormat.Json => new JsonMessageWriter(stream, context),
|
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context);
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format), $"Unknown export format '{format}'.")
|
await writer.WritePreambleAsync(cancellationToken);
|
||||||
};
|
|
||||||
}
|
return _writer = writer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask ExportMessageAsync(Message message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var writer = await GetWriterAsync(cancellationToken);
|
||||||
|
await writer.WriteMessageAsync(message, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class MessageExporter
|
||||||
|
{
|
||||||
|
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
|
||||||
|
{
|
||||||
|
// First partition - don't change file name
|
||||||
|
if (partitionIndex <= 0)
|
||||||
|
return baseFilePath;
|
||||||
|
|
||||||
|
// Inject partition index into file name
|
||||||
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||||
|
var fileExt = Path.GetExtension(baseFilePath);
|
||||||
|
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
||||||
|
var dirPath = Path.GetDirectoryName(baseFilePath);
|
||||||
|
|
||||||
|
return !string.IsNullOrWhiteSpace(dirPath)
|
||||||
|
? Path.Combine(dirPath, fileName)
|
||||||
|
: fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MessageWriter CreateMessageWriter(
|
||||||
|
string filePath,
|
||||||
|
ExportFormat format,
|
||||||
|
ExportContext context)
|
||||||
|
{
|
||||||
|
// Stream will be disposed by the underlying writer
|
||||||
|
var stream = File.Create(filePath);
|
||||||
|
|
||||||
|
return format switch
|
||||||
|
{
|
||||||
|
ExportFormat.PlainText => new PlainTextMessageWriter(stream, context),
|
||||||
|
ExportFormat.Csv => new CsvMessageWriter(stream, context),
|
||||||
|
ExportFormat.HtmlDark => new HtmlMessageWriter(stream, context, "Dark"),
|
||||||
|
ExportFormat.HtmlLight => new HtmlMessageWriter(stream, context, "Light"),
|
||||||
|
ExportFormat.Json => new JsonMessageWriter(stream, context),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(format), $"Unknown export format '{format}'.")
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
namespace DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
|
|
||||||
|
internal class FileSizePartitionLimit : PartitionLimit
|
||||||
{
|
{
|
||||||
internal class FileSizePartitionLimit : PartitionLimit
|
private readonly long _limit;
|
||||||
{
|
|
||||||
private readonly long _limit;
|
|
||||||
|
|
||||||
public FileSizePartitionLimit(long limit) => _limit = limit;
|
public FileSizePartitionLimit(long limit) => _limit = limit;
|
||||||
|
|
||||||
public override bool IsReached(long messagesWritten, long bytesWritten) =>
|
public override bool IsReached(long messagesWritten, long bytesWritten) =>
|
||||||
bytesWritten >= _limit;
|
bytesWritten >= _limit;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
namespace DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
|
|
||||||
|
internal class MessageCountPartitionLimit : PartitionLimit
|
||||||
{
|
{
|
||||||
internal class MessageCountPartitionLimit : PartitionLimit
|
private readonly long _limit;
|
||||||
{
|
|
||||||
private readonly long _limit;
|
|
||||||
|
|
||||||
public MessageCountPartitionLimit(long limit) => _limit = limit;
|
public MessageCountPartitionLimit(long limit) => _limit = limit;
|
||||||
|
|
||||||
public override bool IsReached(long messagesWritten, long bytesWritten) =>
|
public override bool IsReached(long messagesWritten, long bytesWritten) =>
|
||||||
messagesWritten >= _limit;
|
messagesWritten >= _limit;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
namespace DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
|
|
||||||
|
internal class NullPartitionLimit : PartitionLimit
|
||||||
{
|
{
|
||||||
internal class NullPartitionLimit : PartitionLimit
|
public override bool IsReached(long messagesWritten, long bytesWritten) => false;
|
||||||
{
|
|
||||||
public override bool IsReached(long messagesWritten, long bytesWritten) => false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -2,62 +2,61 @@
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
namespace DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
|
|
||||||
|
public abstract partial class PartitionLimit
|
||||||
{
|
{
|
||||||
public abstract partial class PartitionLimit
|
public abstract bool IsReached(long messagesWritten, long bytesWritten);
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class PartitionLimit
|
||||||
|
{
|
||||||
|
public static PartitionLimit Null { get; } = new NullPartitionLimit();
|
||||||
|
|
||||||
|
private static long? TryParseFileSizeBytes(string value, IFormatProvider? formatProvider = null)
|
||||||
{
|
{
|
||||||
public abstract bool IsReached(long messagesWritten, long bytesWritten);
|
var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase);
|
||||||
}
|
|
||||||
|
|
||||||
public partial class PartitionLimit
|
// Number part
|
||||||
{
|
if (!double.TryParse(
|
||||||
public static PartitionLimit Null { get; } = new NullPartitionLimit();
|
|
||||||
|
|
||||||
private static long? TryParseFileSizeBytes(string value, IFormatProvider? formatProvider = null)
|
|
||||||
{
|
|
||||||
var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
// Number part
|
|
||||||
if (!double.TryParse(
|
|
||||||
match.Groups[1].Value,
|
match.Groups[1].Value,
|
||||||
NumberStyles.Float,
|
NumberStyles.Float,
|
||||||
formatProvider,
|
formatProvider,
|
||||||
out var number))
|
out var number))
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magnitude part
|
|
||||||
var magnitude = match.Groups[2].Value.ToUpperInvariant() switch
|
|
||||||
{
|
|
||||||
"G" => 1_000_000_000,
|
|
||||||
"M" => 1_000_000,
|
|
||||||
"K" => 1_000,
|
|
||||||
"" => 1,
|
|
||||||
_ => -1
|
|
||||||
};
|
|
||||||
|
|
||||||
if (magnitude < 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (long) (number * magnitude);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PartitionLimit? TryParse(string value, IFormatProvider? formatProvider = null)
|
|
||||||
{
|
{
|
||||||
var fileSizeLimit = TryParseFileSizeBytes(value, formatProvider);
|
|
||||||
if (fileSizeLimit is not null)
|
|
||||||
return new FileSizePartitionLimit(fileSizeLimit.Value);
|
|
||||||
|
|
||||||
if (int.TryParse(value, NumberStyles.Integer, formatProvider, out var messageCountLimit))
|
|
||||||
return new MessageCountPartitionLimit(messageCountLimit);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PartitionLimit Parse(string value, IFormatProvider? formatProvider = null) =>
|
// Magnitude part
|
||||||
TryParse(value, formatProvider) ?? throw new FormatException($"Invalid partition limit '{value}'.");
|
var magnitude = match.Groups[2].Value.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"G" => 1_000_000_000,
|
||||||
|
"M" => 1_000_000,
|
||||||
|
"K" => 1_000,
|
||||||
|
"" => 1,
|
||||||
|
_ => -1
|
||||||
|
};
|
||||||
|
|
||||||
|
if (magnitude < 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (long) (number * magnitude);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PartitionLimit? TryParse(string value, IFormatProvider? formatProvider = null)
|
||||||
|
{
|
||||||
|
var fileSizeLimit = TryParseFileSizeBytes(value, formatProvider);
|
||||||
|
if (fileSizeLimit is not null)
|
||||||
|
return new FileSizePartitionLimit(fileSizeLimit.Value);
|
||||||
|
|
||||||
|
if (int.TryParse(value, NumberStyles.Integer, formatProvider, out var messageCountLimit))
|
||||||
|
return new MessageCountPartitionLimit(messageCountLimit);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PartitionLimit Parse(string value, IFormatProvider? formatProvider = null) =>
|
||||||
|
TryParse(value, formatProvider) ?? throw new FormatException($"Invalid partition limit '{value}'.");
|
||||||
}
|
}
|
||||||
|
|
@ -7,110 +7,109 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
namespace DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
|
internal partial class CsvMessageWriter : MessageWriter
|
||||||
{
|
{
|
||||||
internal partial class CsvMessageWriter : MessageWriter
|
private readonly TextWriter _writer;
|
||||||
|
|
||||||
|
public CsvMessageWriter(Stream stream, ExportContext context)
|
||||||
|
: base(stream, context)
|
||||||
{
|
{
|
||||||
private readonly TextWriter _writer;
|
_writer = new StreamWriter(stream);
|
||||||
|
|
||||||
public CsvMessageWriter(Stream stream, ExportContext context)
|
|
||||||
: base(stream, context)
|
|
||||||
{
|
|
||||||
_writer = new StreamWriter(stream);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatMarkdown(string? markdown) =>
|
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
|
|
||||||
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
|
||||||
|
|
||||||
private async ValueTask WriteAttachmentsAsync(
|
|
||||||
IReadOnlyList<Attachment> attachments,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var attachment in attachments)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
buffer
|
|
||||||
.AppendIfNotEmpty(',')
|
|
||||||
.Append(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteReactionsAsync(
|
|
||||||
IReadOnlyList<Reaction> reactions,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var reaction in reactions)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
buffer
|
|
||||||
.AppendIfNotEmpty(',')
|
|
||||||
.Append(reaction.Emoji.Name)
|
|
||||||
.Append(' ')
|
|
||||||
.Append('(')
|
|
||||||
.Append(reaction.Count)
|
|
||||||
.Append(')');
|
|
||||||
}
|
|
||||||
|
|
||||||
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask WriteMessageAsync(
|
|
||||||
Message message,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await base.WriteMessageAsync(message, cancellationToken);
|
|
||||||
|
|
||||||
// Author ID
|
|
||||||
await _writer.WriteAsync(CsvEncode(message.Author.Id.ToString()));
|
|
||||||
await _writer.WriteAsync(',');
|
|
||||||
|
|
||||||
// Author name
|
|
||||||
await _writer.WriteAsync(CsvEncode(message.Author.FullName));
|
|
||||||
await _writer.WriteAsync(',');
|
|
||||||
|
|
||||||
// Message timestamp
|
|
||||||
await _writer.WriteAsync(CsvEncode(Context.FormatDate(message.Timestamp)));
|
|
||||||
await _writer.WriteAsync(',');
|
|
||||||
|
|
||||||
// Message content
|
|
||||||
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content)));
|
|
||||||
await _writer.WriteAsync(',');
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
|
||||||
await _writer.WriteAsync(',');
|
|
||||||
|
|
||||||
// Reactions
|
|
||||||
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
|
||||||
|
|
||||||
// Finish row
|
|
||||||
await _writer.WriteLineAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await _writer.DisposeAsync();
|
|
||||||
await base.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class CsvMessageWriter
|
private string FormatMarkdown(string? markdown) =>
|
||||||
|
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||||
|
|
||||||
|
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
|
||||||
|
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
||||||
|
|
||||||
|
private async ValueTask WriteAttachmentsAsync(
|
||||||
|
IReadOnlyList<Attachment> attachments,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
private static string CsvEncode(string value)
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var attachment in attachments)
|
||||||
{
|
{
|
||||||
value = value.Replace("\"", "\"\"");
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
return $"\"{value}\"";
|
|
||||||
|
buffer
|
||||||
|
.AppendIfNotEmpty(',')
|
||||||
|
.Append(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteReactionsAsync(
|
||||||
|
IReadOnlyList<Reaction> reactions,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var reaction in reactions)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
buffer
|
||||||
|
.AppendIfNotEmpty(',')
|
||||||
|
.Append(reaction.Emoji.Name)
|
||||||
|
.Append(' ')
|
||||||
|
.Append('(')
|
||||||
|
.Append(reaction.Count)
|
||||||
|
.Append(')');
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask WriteMessageAsync(
|
||||||
|
Message message,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await base.WriteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
|
// Author ID
|
||||||
|
await _writer.WriteAsync(CsvEncode(message.Author.Id.ToString()));
|
||||||
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
|
// Author name
|
||||||
|
await _writer.WriteAsync(CsvEncode(message.Author.FullName));
|
||||||
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
|
// Message timestamp
|
||||||
|
await _writer.WriteAsync(CsvEncode(Context.FormatDate(message.Timestamp)));
|
||||||
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
|
// Message content
|
||||||
|
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content)));
|
||||||
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||||
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
||||||
|
|
||||||
|
// Finish row
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _writer.DisposeAsync();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class CsvMessageWriter
|
||||||
|
{
|
||||||
|
private static string CsvEncode(string value)
|
||||||
|
{
|
||||||
|
value = value.Replace("\"", "\"\"");
|
||||||
|
return $"\"{value}\"";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,59 +3,58 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
|
// Used for grouping contiguous messages in HTML export
|
||||||
|
internal partial class MessageGroup
|
||||||
{
|
{
|
||||||
// Used for grouping contiguous messages in HTML export
|
public User Author { get; }
|
||||||
internal partial class MessageGroup
|
|
||||||
|
public DateTimeOffset Timestamp { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Message> Messages { get; }
|
||||||
|
|
||||||
|
public MessageReference? Reference { get; }
|
||||||
|
|
||||||
|
public Message? ReferencedMessage {get; }
|
||||||
|
|
||||||
|
public MessageGroup(
|
||||||
|
User author,
|
||||||
|
DateTimeOffset timestamp,
|
||||||
|
MessageReference? reference,
|
||||||
|
Message? referencedMessage,
|
||||||
|
IReadOnlyList<Message> messages)
|
||||||
{
|
{
|
||||||
public User Author { get; }
|
Author = author;
|
||||||
|
Timestamp = timestamp;
|
||||||
public DateTimeOffset Timestamp { get; }
|
Reference = reference;
|
||||||
|
ReferencedMessage = referencedMessage;
|
||||||
public IReadOnlyList<Message> Messages { get; }
|
Messages = messages;
|
||||||
|
}
|
||||||
public MessageReference? Reference { get; }
|
}
|
||||||
|
|
||||||
public Message? ReferencedMessage {get; }
|
internal partial class MessageGroup
|
||||||
|
{
|
||||||
public MessageGroup(
|
public static bool CanJoin(Message message1, Message message2) =>
|
||||||
User author,
|
// Must be from the same author
|
||||||
DateTimeOffset timestamp,
|
message1.Author.Id == message2.Author.Id &&
|
||||||
MessageReference? reference,
|
// Author's name must not have changed between messages
|
||||||
Message? referencedMessage,
|
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
||||||
IReadOnlyList<Message> messages)
|
// Duration between messages must be 7 minutes or less
|
||||||
{
|
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
|
||||||
Author = author;
|
// Other message must not be a reply
|
||||||
Timestamp = timestamp;
|
message2.Reference is null;
|
||||||
Reference = reference;
|
|
||||||
ReferencedMessage = referencedMessage;
|
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
||||||
Messages = messages;
|
{
|
||||||
}
|
var first = messages.First();
|
||||||
}
|
|
||||||
|
return new MessageGroup(
|
||||||
internal partial class MessageGroup
|
first.Author,
|
||||||
{
|
first.Timestamp,
|
||||||
public static bool CanJoin(Message message1, Message message2) =>
|
first.Reference,
|
||||||
// Must be from the same author
|
first.ReferencedMessage,
|
||||||
message1.Author.Id == message2.Author.Id &&
|
messages
|
||||||
// Author's name must not have changed between messages
|
);
|
||||||
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
|
||||||
// Duration between messages must be 7 minutes or less
|
|
||||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
|
|
||||||
// Other message must not be a reply
|
|
||||||
message2.Reference is null;
|
|
||||||
|
|
||||||
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
var first = messages.First();
|
|
||||||
|
|
||||||
return new MessageGroup(
|
|
||||||
first.Author,
|
|
||||||
first.Timestamp,
|
|
||||||
first.Reference,
|
|
||||||
first.ReferencedMessage,
|
|
||||||
messages
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
|
internal class MessageGroupTemplateContext
|
||||||
{
|
{
|
||||||
internal class MessageGroupTemplateContext
|
public ExportContext ExportContext { get; }
|
||||||
|
|
||||||
|
public MessageGroup MessageGroup { get; }
|
||||||
|
|
||||||
|
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
|
||||||
{
|
{
|
||||||
public ExportContext ExportContext { get; }
|
ExportContext = exportContext;
|
||||||
|
MessageGroup = messageGroup;
|
||||||
public MessageGroup MessageGroup { get; }
|
|
||||||
|
|
||||||
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
|
|
||||||
{
|
|
||||||
ExportContext = exportContext;
|
|
||||||
MessageGroup = messageGroup;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
|
||||||
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||||
|
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
||||||
}
|
}
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
|
internal class PostambleTemplateContext
|
||||||
{
|
{
|
||||||
internal class PostambleTemplateContext
|
public ExportContext ExportContext { get; }
|
||||||
|
|
||||||
|
public long MessagesWritten { get; }
|
||||||
|
|
||||||
|
public PostambleTemplateContext(ExportContext exportContext, long messagesWritten)
|
||||||
{
|
{
|
||||||
public ExportContext ExportContext { get; }
|
ExportContext = exportContext;
|
||||||
|
MessagesWritten = messagesWritten;
|
||||||
public long MessagesWritten { get; }
|
|
||||||
|
|
||||||
public PostambleTemplateContext(ExportContext exportContext, long messagesWritten)
|
|
||||||
{
|
|
||||||
ExportContext = exportContext;
|
|
||||||
MessagesWritten = messagesWritten;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
|
internal class PreambleTemplateContext
|
||||||
{
|
{
|
||||||
internal class PreambleTemplateContext
|
public ExportContext ExportContext { get; }
|
||||||
|
|
||||||
|
public string ThemeName { get; }
|
||||||
|
|
||||||
|
public PreambleTemplateContext(ExportContext exportContext, string themeName)
|
||||||
{
|
{
|
||||||
public ExportContext ExportContext { get; }
|
ExportContext = exportContext;
|
||||||
|
ThemeName = themeName;
|
||||||
public string ThemeName { get; }
|
|
||||||
|
|
||||||
public PreambleTemplateContext(ExportContext exportContext, string themeName)
|
|
||||||
{
|
|
||||||
ExportContext = exportContext;
|
|
||||||
ThemeName = themeName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
|
||||||
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||||
|
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
||||||
}
|
}
|
||||||
|
|
@ -6,91 +6,90 @@ using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Writers.Html;
|
using DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
namespace DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
|
internal class HtmlMessageWriter : MessageWriter
|
||||||
{
|
{
|
||||||
internal class HtmlMessageWriter : MessageWriter
|
private readonly TextWriter _writer;
|
||||||
|
private readonly string _themeName;
|
||||||
|
|
||||||
|
private readonly List<Message> _messageGroupBuffer = new();
|
||||||
|
|
||||||
|
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
||||||
|
: base(stream, context)
|
||||||
{
|
{
|
||||||
private readonly TextWriter _writer;
|
_writer = new StreamWriter(stream);
|
||||||
private readonly string _themeName;
|
_themeName = themeName;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly List<Message> _messageGroupBuffer = new();
|
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
||||||
|
|
||||||
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
// We are not writing directly to output because Razor
|
||||||
: base(stream, context)
|
// does not actually do asynchronous writes to stream.
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await PreambleTemplate.RenderAsync(templateContext, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteMessageGroupAsync(
|
||||||
|
MessageGroup messageGroup,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
||||||
|
|
||||||
|
// We are not writing directly to output because Razor
|
||||||
|
// does not actually do asynchronous writes to stream.
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await MessageGroupTemplate.RenderAsync(templateContext, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask WriteMessageAsync(
|
||||||
|
Message message,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await base.WriteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
|
// If message group is empty or the given message can be grouped, buffer the given message
|
||||||
|
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
|
||||||
{
|
{
|
||||||
_writer = new StreamWriter(stream);
|
_messageGroupBuffer.Add(message);
|
||||||
_themeName = themeName;
|
|
||||||
}
|
}
|
||||||
|
// Otherwise, flush the group and render messages
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
else
|
||||||
{
|
{
|
||||||
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken);
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
_messageGroupBuffer.Clear();
|
||||||
// does not actually do asynchronous writes to stream.
|
_messageGroupBuffer.Add(message);
|
||||||
await _writer.WriteLineAsync(
|
|
||||||
await PreambleTemplate.RenderAsync(templateContext, cancellationToken)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteMessageGroupAsync(
|
|
||||||
MessageGroup messageGroup,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
|
||||||
// does not actually do asynchronous writes to stream.
|
|
||||||
await _writer.WriteLineAsync(
|
|
||||||
await MessageGroupTemplate.RenderAsync(templateContext, cancellationToken)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask WriteMessageAsync(
|
|
||||||
Message message,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await base.WriteMessageAsync(message, cancellationToken);
|
|
||||||
|
|
||||||
// If message group is empty or the given message can be grouped, buffer the given message
|
|
||||||
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
|
|
||||||
{
|
|
||||||
_messageGroupBuffer.Add(message);
|
|
||||||
}
|
|
||||||
// Otherwise, flush the group and render messages
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken);
|
|
||||||
|
|
||||||
_messageGroupBuffer.Clear();
|
|
||||||
_messageGroupBuffer.Add(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Flush current message group
|
|
||||||
if (_messageGroupBuffer.Any())
|
|
||||||
{
|
|
||||||
await WriteMessageGroupAsync(
|
|
||||||
MessageGroup.Join(_messageGroupBuffer),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
|
||||||
// does not actually do asynchronous writes to stream.
|
|
||||||
await _writer.WriteLineAsync(
|
|
||||||
await PostambleTemplate.RenderAsync(templateContext, cancellationToken)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await _writer.DisposeAsync();
|
|
||||||
await base.DisposeAsync();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Flush current message group
|
||||||
|
if (_messageGroupBuffer.Any())
|
||||||
|
{
|
||||||
|
await WriteMessageGroupAsync(
|
||||||
|
MessageGroup.Join(_messageGroupBuffer),
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
||||||
|
|
||||||
|
// We are not writing directly to output because Razor
|
||||||
|
// does not actually do asynchronous writes to stream.
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await PostambleTemplate.RenderAsync(templateContext, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _writer.DisposeAsync();
|
||||||
|
await base.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,319 +9,318 @@ using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using JsonExtensions.Writing;
|
using JsonExtensions.Writing;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
namespace DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
|
internal class JsonMessageWriter : MessageWriter
|
||||||
{
|
{
|
||||||
internal class JsonMessageWriter : MessageWriter
|
private readonly Utf8JsonWriter _writer;
|
||||||
|
|
||||||
|
public JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
|
: base(stream, context)
|
||||||
{
|
{
|
||||||
private readonly Utf8JsonWriter _writer;
|
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||||
|
|
||||||
public JsonMessageWriter(Stream stream, ExportContext context)
|
|
||||||
: base(stream, context)
|
|
||||||
{
|
{
|
||||||
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
{
|
Indented = true,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
// Validation errors may mask actual failures
|
||||||
Indented = true,
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||||
// Validation errors may mask actual failures
|
SkipValidation = true
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
});
|
||||||
SkipValidation = true
|
}
|
||||||
});
|
|
||||||
|
private string FormatMarkdown(string? markdown) =>
|
||||||
|
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||||
|
|
||||||
|
private async ValueTask WriteAttachmentAsync(
|
||||||
|
Attachment attachment,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", attachment.Id.ToString());
|
||||||
|
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
||||||
|
_writer.WriteString("fileName", attachment.FileName);
|
||||||
|
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteEmbedAuthorAsync(
|
||||||
|
EmbedAuthor embedAuthor,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("author");
|
||||||
|
|
||||||
|
_writer.WriteString("name", embedAuthor.Name);
|
||||||
|
_writer.WriteString("url", embedAuthor.Url);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
|
||||||
|
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken));
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteEmbedThumbnailAsync(
|
||||||
|
EmbedImage embedThumbnail,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("thumbnail");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedThumbnail.Url))
|
||||||
|
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.ProxyUrl ?? embedThumbnail.Url, cancellationToken));
|
||||||
|
|
||||||
|
_writer.WriteNumber("width", embedThumbnail.Width);
|
||||||
|
_writer.WriteNumber("height", embedThumbnail.Height);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteEmbedImageAsync(
|
||||||
|
EmbedImage embedImage,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("image");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
||||||
|
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken));
|
||||||
|
|
||||||
|
_writer.WriteNumber("width", embedImage.Width);
|
||||||
|
_writer.WriteNumber("height", embedImage.Height);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteEmbedFooterAsync(
|
||||||
|
EmbedFooter embedFooter,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("footer");
|
||||||
|
|
||||||
|
_writer.WriteString("text", embedFooter.Text);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
|
||||||
|
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken));
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteEmbedFieldAsync(
|
||||||
|
EmbedField embedField,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("name", FormatMarkdown(embedField.Name));
|
||||||
|
_writer.WriteString("value", FormatMarkdown(embedField.Value));
|
||||||
|
_writer.WriteBoolean("isInline", embedField.IsInline);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteEmbedAsync(
|
||||||
|
Embed embed,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("title", FormatMarkdown(embed.Title));
|
||||||
|
_writer.WriteString("url", embed.Url);
|
||||||
|
_writer.WriteString("timestamp", embed.Timestamp);
|
||||||
|
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
||||||
|
|
||||||
|
if (embed.Color is not null)
|
||||||
|
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||||
|
|
||||||
|
if (embed.Author is not null)
|
||||||
|
await WriteEmbedAuthorAsync(embed.Author, cancellationToken);
|
||||||
|
|
||||||
|
if (embed.Thumbnail is not null)
|
||||||
|
await WriteEmbedThumbnailAsync(embed.Thumbnail, cancellationToken);
|
||||||
|
|
||||||
|
if (embed.Image is not null)
|
||||||
|
await WriteEmbedImageAsync(embed.Image, cancellationToken);
|
||||||
|
|
||||||
|
if (embed.Footer is not null)
|
||||||
|
await WriteEmbedFooterAsync(embed.Footer, cancellationToken);
|
||||||
|
|
||||||
|
// Fields
|
||||||
|
_writer.WriteStartArray("fields");
|
||||||
|
|
||||||
|
foreach (var field in embed.Fields)
|
||||||
|
await WriteEmbedFieldAsync(field, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteReactionAsync(
|
||||||
|
Reaction reaction,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
// Emoji
|
||||||
|
_writer.WriteStartObject("emoji");
|
||||||
|
_writer.WriteString("id", reaction.Emoji.Id);
|
||||||
|
_writer.WriteString("name", reaction.Emoji.Name);
|
||||||
|
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
||||||
|
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
|
_writer.WriteNumber("count", reaction.Count);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteMentionAsync(
|
||||||
|
User mentionedUser,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", mentionedUser.Id.ToString());
|
||||||
|
_writer.WriteString("name", mentionedUser.Name);
|
||||||
|
_writer.WriteString("discriminator", mentionedUser.DiscriminatorFormatted);
|
||||||
|
_writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name);
|
||||||
|
_writer.WriteBoolean("isBot", mentionedUser.IsBot);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Root object (start)
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
// Guild
|
||||||
|
_writer.WriteStartObject("guild");
|
||||||
|
_writer.WriteString("id", Context.Request.Guild.Id.ToString());
|
||||||
|
_writer.WriteString("name", Context.Request.Guild.Name);
|
||||||
|
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl, cancellationToken));
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
|
// Channel
|
||||||
|
_writer.WriteStartObject("channel");
|
||||||
|
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
||||||
|
_writer.WriteString("type", Context.Request.Channel.Kind.ToString());
|
||||||
|
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString());
|
||||||
|
_writer.WriteString("category", Context.Request.Channel.Category.Name);
|
||||||
|
_writer.WriteString("name", Context.Request.Channel.Name);
|
||||||
|
_writer.WriteString("topic", Context.Request.Channel.Topic);
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
_writer.WriteStartObject("dateRange");
|
||||||
|
_writer.WriteString("after", Context.Request.After?.ToDate());
|
||||||
|
_writer.WriteString("before", Context.Request.Before?.ToDate());
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
|
// Message array (start)
|
||||||
|
_writer.WriteStartArray("messages");
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask WriteMessageAsync(
|
||||||
|
Message message,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await base.WriteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
_writer.WriteString("id", message.Id.ToString());
|
||||||
|
_writer.WriteString("type", message.Kind.ToString());
|
||||||
|
_writer.WriteString("timestamp", message.Timestamp);
|
||||||
|
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
||||||
|
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
|
||||||
|
_writer.WriteBoolean("isPinned", message.IsPinned);
|
||||||
|
|
||||||
|
// Content
|
||||||
|
_writer.WriteString("content", FormatMarkdown(message.Content));
|
||||||
|
|
||||||
|
// Author
|
||||||
|
_writer.WriteStartObject("author");
|
||||||
|
_writer.WriteString("id", message.Author.Id.ToString());
|
||||||
|
_writer.WriteString("name", message.Author.Name);
|
||||||
|
_writer.WriteString("discriminator", message.Author.DiscriminatorFormatted);
|
||||||
|
_writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name);
|
||||||
|
_writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
|
||||||
|
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
||||||
|
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl, cancellationToken));
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
|
foreach (var attachment in message.Attachments)
|
||||||
|
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Embeds
|
||||||
|
_writer.WriteStartArray("embeds");
|
||||||
|
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
await WriteEmbedAsync(embed, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
_writer.WriteStartArray("reactions");
|
||||||
|
|
||||||
|
foreach (var reaction in message.Reactions)
|
||||||
|
await WriteReactionAsync(reaction, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Mentions
|
||||||
|
_writer.WriteStartArray("mentions");
|
||||||
|
|
||||||
|
foreach (var mention in message.MentionedUsers)
|
||||||
|
await WriteMentionAsync(mention, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Message reference
|
||||||
|
if (message.Reference is not null)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("reference");
|
||||||
|
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||||
|
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||||
|
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||||
|
_writer.WriteEndObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatMarkdown(string? markdown) =>
|
_writer.WriteEndObject();
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
private async ValueTask WriteAttachmentAsync(
|
|
||||||
Attachment attachment,
|
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
||||||
CancellationToken cancellationToken = default)
|
{
|
||||||
{
|
// Message array (end)
|
||||||
_writer.WriteStartObject();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
_writer.WriteString("id", attachment.Id.ToString());
|
_writer.WriteNumber("messageCount", MessagesWritten);
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
|
||||||
_writer.WriteString("fileName", attachment.FileName);
|
// Root object (end)
|
||||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
_writer.WriteEndObject();
|
}
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
private async ValueTask WriteEmbedAuthorAsync(
|
await _writer.DisposeAsync();
|
||||||
EmbedAuthor embedAuthor,
|
await base.DisposeAsync();
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject("author");
|
|
||||||
|
|
||||||
_writer.WriteString("name", embedAuthor.Name);
|
|
||||||
_writer.WriteString("url", embedAuthor.Url);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
|
|
||||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken));
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteEmbedThumbnailAsync(
|
|
||||||
EmbedImage embedThumbnail,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject("thumbnail");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedThumbnail.Url))
|
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.ProxyUrl ?? embedThumbnail.Url, cancellationToken));
|
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedThumbnail.Width);
|
|
||||||
_writer.WriteNumber("height", embedThumbnail.Height);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteEmbedImageAsync(
|
|
||||||
EmbedImage embedImage,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject("image");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken));
|
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedImage.Width);
|
|
||||||
_writer.WriteNumber("height", embedImage.Height);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteEmbedFooterAsync(
|
|
||||||
EmbedFooter embedFooter,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject("footer");
|
|
||||||
|
|
||||||
_writer.WriteString("text", embedFooter.Text);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
|
|
||||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken));
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteEmbedFieldAsync(
|
|
||||||
EmbedField embedField,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("name", FormatMarkdown(embedField.Name));
|
|
||||||
_writer.WriteString("value", FormatMarkdown(embedField.Value));
|
|
||||||
_writer.WriteBoolean("isInline", embedField.IsInline);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteEmbedAsync(
|
|
||||||
Embed embed,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("title", FormatMarkdown(embed.Title));
|
|
||||||
_writer.WriteString("url", embed.Url);
|
|
||||||
_writer.WriteString("timestamp", embed.Timestamp);
|
|
||||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
|
||||||
|
|
||||||
if (embed.Color is not null)
|
|
||||||
_writer.WriteString("color", embed.Color.Value.ToHex());
|
|
||||||
|
|
||||||
if (embed.Author is not null)
|
|
||||||
await WriteEmbedAuthorAsync(embed.Author, cancellationToken);
|
|
||||||
|
|
||||||
if (embed.Thumbnail is not null)
|
|
||||||
await WriteEmbedThumbnailAsync(embed.Thumbnail, cancellationToken);
|
|
||||||
|
|
||||||
if (embed.Image is not null)
|
|
||||||
await WriteEmbedImageAsync(embed.Image, cancellationToken);
|
|
||||||
|
|
||||||
if (embed.Footer is not null)
|
|
||||||
await WriteEmbedFooterAsync(embed.Footer, cancellationToken);
|
|
||||||
|
|
||||||
// Fields
|
|
||||||
_writer.WriteStartArray("fields");
|
|
||||||
|
|
||||||
foreach (var field in embed.Fields)
|
|
||||||
await WriteEmbedFieldAsync(field, cancellationToken);
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteReactionAsync(
|
|
||||||
Reaction reaction,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
// Emoji
|
|
||||||
_writer.WriteStartObject("emoji");
|
|
||||||
_writer.WriteString("id", reaction.Emoji.Id);
|
|
||||||
_writer.WriteString("name", reaction.Emoji.Name);
|
|
||||||
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
|
||||||
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
|
|
||||||
_writer.WriteNumber("count", reaction.Count);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteMentionAsync(
|
|
||||||
User mentionedUser,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("id", mentionedUser.Id.ToString());
|
|
||||||
_writer.WriteString("name", mentionedUser.Name);
|
|
||||||
_writer.WriteString("discriminator", mentionedUser.DiscriminatorFormatted);
|
|
||||||
_writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name);
|
|
||||||
_writer.WriteBoolean("isBot", mentionedUser.IsBot);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Root object (start)
|
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
// Guild
|
|
||||||
_writer.WriteStartObject("guild");
|
|
||||||
_writer.WriteString("id", Context.Request.Guild.Id.ToString());
|
|
||||||
_writer.WriteString("name", Context.Request.Guild.Name);
|
|
||||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl, cancellationToken));
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
|
|
||||||
// Channel
|
|
||||||
_writer.WriteStartObject("channel");
|
|
||||||
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
|
||||||
_writer.WriteString("type", Context.Request.Channel.Kind.ToString());
|
|
||||||
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString());
|
|
||||||
_writer.WriteString("category", Context.Request.Channel.Category.Name);
|
|
||||||
_writer.WriteString("name", Context.Request.Channel.Name);
|
|
||||||
_writer.WriteString("topic", Context.Request.Channel.Topic);
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
|
|
||||||
// Date range
|
|
||||||
_writer.WriteStartObject("dateRange");
|
|
||||||
_writer.WriteString("after", Context.Request.After?.ToDate());
|
|
||||||
_writer.WriteString("before", Context.Request.Before?.ToDate());
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
|
|
||||||
// Message array (start)
|
|
||||||
_writer.WriteStartArray("messages");
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask WriteMessageAsync(
|
|
||||||
Message message,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
await base.WriteMessageAsync(message, cancellationToken);
|
|
||||||
|
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
_writer.WriteString("id", message.Id.ToString());
|
|
||||||
_writer.WriteString("type", message.Kind.ToString());
|
|
||||||
_writer.WriteString("timestamp", message.Timestamp);
|
|
||||||
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
|
||||||
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
|
|
||||||
_writer.WriteBoolean("isPinned", message.IsPinned);
|
|
||||||
|
|
||||||
// Content
|
|
||||||
_writer.WriteString("content", FormatMarkdown(message.Content));
|
|
||||||
|
|
||||||
// Author
|
|
||||||
_writer.WriteStartObject("author");
|
|
||||||
_writer.WriteString("id", message.Author.Id.ToString());
|
|
||||||
_writer.WriteString("name", message.Author.Name);
|
|
||||||
_writer.WriteString("discriminator", message.Author.DiscriminatorFormatted);
|
|
||||||
_writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name);
|
|
||||||
_writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
|
|
||||||
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
|
||||||
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl, cancellationToken));
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
_writer.WriteStartArray("attachments");
|
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments)
|
|
||||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
|
||||||
|
|
||||||
// Embeds
|
|
||||||
_writer.WriteStartArray("embeds");
|
|
||||||
|
|
||||||
foreach (var embed in message.Embeds)
|
|
||||||
await WriteEmbedAsync(embed, cancellationToken);
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
|
||||||
|
|
||||||
// Reactions
|
|
||||||
_writer.WriteStartArray("reactions");
|
|
||||||
|
|
||||||
foreach (var reaction in message.Reactions)
|
|
||||||
await WriteReactionAsync(reaction, cancellationToken);
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
|
||||||
|
|
||||||
// Mentions
|
|
||||||
_writer.WriteStartArray("mentions");
|
|
||||||
|
|
||||||
foreach (var mention in message.MentionedUsers)
|
|
||||||
await WriteMentionAsync(mention, cancellationToken);
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
|
||||||
|
|
||||||
// Message reference
|
|
||||||
if (message.Reference is not null)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject("reference");
|
|
||||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
|
||||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
|
||||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
// Message array (end)
|
|
||||||
_writer.WriteEndArray();
|
|
||||||
|
|
||||||
_writer.WriteNumber("messageCount", MessagesWritten);
|
|
||||||
|
|
||||||
// Root object (end)
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await _writer.DisposeAsync();
|
|
||||||
await base.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -9,185 +9,184 @@ using DiscordChatExporter.Core.Markdown;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
|
internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||||
{
|
{
|
||||||
internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
private readonly ExportContext _context;
|
||||||
|
private readonly StringBuilder _buffer;
|
||||||
|
private readonly bool _isJumbo;
|
||||||
|
|
||||||
|
public HtmlMarkdownVisitor(ExportContext context, StringBuilder buffer, bool isJumbo)
|
||||||
{
|
{
|
||||||
private readonly ExportContext _context;
|
_context = context;
|
||||||
private readonly StringBuilder _buffer;
|
_buffer = buffer;
|
||||||
private readonly bool _isJumbo;
|
_isJumbo = isJumbo;
|
||||||
|
|
||||||
public HtmlMarkdownVisitor(ExportContext context, StringBuilder buffer, bool isJumbo)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_buffer = buffer;
|
|
||||||
_isJumbo = isJumbo;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitText(TextNode text)
|
|
||||||
{
|
|
||||||
_buffer.Append(HtmlEncode(text.Text));
|
|
||||||
return base.VisitText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitFormatting(FormattingNode formatting)
|
|
||||||
{
|
|
||||||
var (tagOpen, tagClose) = formatting.Kind switch
|
|
||||||
{
|
|
||||||
FormattingKind.Bold => ("<strong>", "</strong>"),
|
|
||||||
FormattingKind.Italic => ("<em>", "</em>"),
|
|
||||||
FormattingKind.Underline => ("<u>", "</u>"),
|
|
||||||
FormattingKind.Strikethrough => ("<s>", "</s>"),
|
|
||||||
FormattingKind.Spoiler => (
|
|
||||||
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
|
|
||||||
FormattingKind.Quote => ("<div class=\"quote\">", "</div>"),
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
|
|
||||||
};
|
|
||||||
|
|
||||||
_buffer.Append(tagOpen);
|
|
||||||
var result = base.VisitFormatting(formatting);
|
|
||||||
_buffer.Append(tagClose);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
|
||||||
{
|
|
||||||
_buffer
|
|
||||||
.Append("<span class=\"pre pre--inline\">")
|
|
||||||
.Append(HtmlEncode(inlineCodeBlock.Code))
|
|
||||||
.Append("</span>");
|
|
||||||
|
|
||||||
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitLink(LinkNode link)
|
|
||||||
{
|
|
||||||
// Try to extract message ID if the link refers to a Discord message
|
|
||||||
var linkedMessageId = Regex.Match(
|
|
||||||
link.Url,
|
|
||||||
"^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$"
|
|
||||||
).Groups[1].Value;
|
|
||||||
|
|
||||||
_buffer.Append(
|
|
||||||
!string.IsNullOrWhiteSpace(linkedMessageId)
|
|
||||||
? $"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
|
|
||||||
: $"<a href=\"{Uri.EscapeUriString(link.Url)}\">"
|
|
||||||
);
|
|
||||||
|
|
||||||
var result = base.VisitLink(link);
|
|
||||||
_buffer.Append("</a>");
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
|
||||||
{
|
|
||||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
|
||||||
var jumboClass = _isJumbo ? "emoji--large" : "";
|
|
||||||
|
|
||||||
_buffer
|
|
||||||
.Append($"<img loading=\"lazy\" class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
|
||||||
|
|
||||||
return base.VisitEmoji(emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
|
||||||
{
|
|
||||||
var mentionId = Snowflake.TryParse(mention.Id);
|
|
||||||
if (mention.Kind == MentionKind.Meta)
|
|
||||||
{
|
|
||||||
_buffer
|
|
||||||
.Append("<span class=\"mention\">")
|
|
||||||
.Append("@").Append(HtmlEncode(mention.Id))
|
|
||||||
.Append("</span>");
|
|
||||||
}
|
|
||||||
else if (mention.Kind == MentionKind.User)
|
|
||||||
{
|
|
||||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
|
||||||
var fullName = member?.User.FullName ?? "Unknown";
|
|
||||||
var nick = member?.Nick ?? "Unknown";
|
|
||||||
|
|
||||||
_buffer
|
|
||||||
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
|
|
||||||
.Append("@").Append(HtmlEncode(nick))
|
|
||||||
.Append("</span>");
|
|
||||||
}
|
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
|
||||||
{
|
|
||||||
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
|
||||||
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
|
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
|
||||||
|
|
||||||
_buffer
|
|
||||||
.Append("<span class=\"mention\">")
|
|
||||||
.Append(symbol).Append(HtmlEncode(name))
|
|
||||||
.Append("</span>");
|
|
||||||
}
|
|
||||||
else if (mention.Kind == MentionKind.Role)
|
|
||||||
{
|
|
||||||
var role = mentionId?.Pipe(_context.TryGetRole);
|
|
||||||
var name = role?.Name ?? "deleted-role";
|
|
||||||
var color = role?.Color;
|
|
||||||
|
|
||||||
var style = color is not null
|
|
||||||
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
|
|
||||||
: "";
|
|
||||||
|
|
||||||
_buffer
|
|
||||||
.Append($"<span class=\"mention\" style=\"{style}\">")
|
|
||||||
.Append("@").Append(HtmlEncode(name))
|
|
||||||
.Append("</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
return base.VisitMention(mention);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
|
||||||
{
|
|
||||||
// Timestamp tooltips always use full date regardless of the configured format
|
|
||||||
var longDateString = timestamp.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt");
|
|
||||||
|
|
||||||
_buffer
|
|
||||||
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
|
||||||
.Append(HtmlEncode(_context.FormatDate(timestamp.Value)))
|
|
||||||
.Append("</span>");
|
|
||||||
|
|
||||||
return base.VisitUnixTimestamp(timestamp);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class HtmlMarkdownVisitor
|
protected override MarkdownNode VisitText(TextNode text)
|
||||||
{
|
{
|
||||||
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
|
_buffer.Append(HtmlEncode(text.Text));
|
||||||
|
return base.VisitText(text);
|
||||||
|
}
|
||||||
|
|
||||||
public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true)
|
protected override MarkdownNode VisitFormatting(FormattingNode formatting)
|
||||||
|
{
|
||||||
|
var (tagOpen, tagClose) = formatting.Kind switch
|
||||||
{
|
{
|
||||||
var nodes = MarkdownParser.Parse(markdown);
|
FormattingKind.Bold => ("<strong>", "</strong>"),
|
||||||
|
FormattingKind.Italic => ("<em>", "</em>"),
|
||||||
|
FormattingKind.Underline => ("<u>", "</u>"),
|
||||||
|
FormattingKind.Strikethrough => ("<s>", "</s>"),
|
||||||
|
FormattingKind.Spoiler => (
|
||||||
|
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
|
||||||
|
FormattingKind.Quote => ("<div class=\"quote\">", "</div>"),
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
|
||||||
|
};
|
||||||
|
|
||||||
var isJumbo =
|
_buffer.Append(tagOpen);
|
||||||
isJumboAllowed &&
|
var result = base.VisitFormatting(formatting);
|
||||||
nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
|
_buffer.Append(tagClose);
|
||||||
|
|
||||||
var buffer = new StringBuilder();
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
new HtmlMarkdownVisitor(context, buffer, isJumbo).Visit(nodes);
|
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
||||||
|
{
|
||||||
|
_buffer
|
||||||
|
.Append("<span class=\"pre pre--inline\">")
|
||||||
|
.Append(HtmlEncode(inlineCodeBlock.Code))
|
||||||
|
.Append("</span>");
|
||||||
|
|
||||||
return buffer.ToString();
|
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MarkdownNode VisitLink(LinkNode link)
|
||||||
|
{
|
||||||
|
// Try to extract message ID if the link refers to a Discord message
|
||||||
|
var linkedMessageId = Regex.Match(
|
||||||
|
link.Url,
|
||||||
|
"^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$"
|
||||||
|
).Groups[1].Value;
|
||||||
|
|
||||||
|
_buffer.Append(
|
||||||
|
!string.IsNullOrWhiteSpace(linkedMessageId)
|
||||||
|
? $"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
|
||||||
|
: $"<a href=\"{Uri.EscapeUriString(link.Url)}\">"
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = base.VisitLink(link);
|
||||||
|
_buffer.Append("</a>");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||||
|
{
|
||||||
|
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||||
|
var jumboClass = _isJumbo ? "emoji--large" : "";
|
||||||
|
|
||||||
|
_buffer
|
||||||
|
.Append($"<img loading=\"lazy\" class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
||||||
|
|
||||||
|
return base.VisitEmoji(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||||
|
{
|
||||||
|
var mentionId = Snowflake.TryParse(mention.Id);
|
||||||
|
if (mention.Kind == MentionKind.Meta)
|
||||||
|
{
|
||||||
|
_buffer
|
||||||
|
.Append("<span class=\"mention\">")
|
||||||
|
.Append("@").Append(HtmlEncode(mention.Id))
|
||||||
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
|
else if (mention.Kind == MentionKind.User)
|
||||||
|
{
|
||||||
|
var member = mentionId?.Pipe(_context.TryGetMember);
|
||||||
|
var fullName = member?.User.FullName ?? "Unknown";
|
||||||
|
var nick = member?.Nick ?? "Unknown";
|
||||||
|
|
||||||
|
_buffer
|
||||||
|
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
|
||||||
|
.Append("@").Append(HtmlEncode(nick))
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
|
{
|
||||||
|
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
||||||
|
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
|
||||||
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
|
_buffer
|
||||||
|
.Append("<span class=\"mention\">")
|
||||||
|
.Append(symbol).Append(HtmlEncode(name))
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Role)
|
||||||
|
{
|
||||||
|
var role = mentionId?.Pipe(_context.TryGetRole);
|
||||||
|
var name = role?.Name ?? "deleted-role";
|
||||||
|
var color = role?.Color;
|
||||||
|
|
||||||
|
var style = color is not null
|
||||||
|
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
_buffer
|
||||||
|
.Append($"<span class=\"mention\" style=\"{style}\">")
|
||||||
|
.Append("@").Append(HtmlEncode(name))
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.VisitMention(mention);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
||||||
|
{
|
||||||
|
// Timestamp tooltips always use full date regardless of the configured format
|
||||||
|
var longDateString = timestamp.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt");
|
||||||
|
|
||||||
|
_buffer
|
||||||
|
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
||||||
|
.Append(HtmlEncode(_context.FormatDate(timestamp.Value)))
|
||||||
|
.Append("</span>");
|
||||||
|
|
||||||
|
return base.VisitUnixTimestamp(timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class HtmlMarkdownVisitor
|
||||||
|
{
|
||||||
|
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
|
||||||
|
|
||||||
|
public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true)
|
||||||
|
{
|
||||||
|
var nodes = MarkdownParser.Parse(markdown);
|
||||||
|
|
||||||
|
var isJumbo =
|
||||||
|
isJumboAllowed &&
|
||||||
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,92 +4,91 @@ using DiscordChatExporter.Core.Markdown;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
|
internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||||
{
|
{
|
||||||
internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
private readonly ExportContext _context;
|
||||||
|
private readonly StringBuilder _buffer;
|
||||||
|
|
||||||
|
public PlainTextMarkdownVisitor(ExportContext context, StringBuilder buffer)
|
||||||
{
|
{
|
||||||
private readonly ExportContext _context;
|
_context = context;
|
||||||
private readonly StringBuilder _buffer;
|
_buffer = buffer;
|
||||||
|
|
||||||
public PlainTextMarkdownVisitor(ExportContext context, StringBuilder buffer)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
_buffer = buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitText(TextNode text)
|
|
||||||
{
|
|
||||||
_buffer.Append(text.Text);
|
|
||||||
return base.VisitText(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
|
||||||
{
|
|
||||||
_buffer.Append(
|
|
||||||
emoji.IsCustomEmoji
|
|
||||||
? $":{emoji.Name}:"
|
|
||||||
: emoji.Name
|
|
||||||
);
|
|
||||||
|
|
||||||
return base.VisitEmoji(emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
|
||||||
{
|
|
||||||
var mentionId = Snowflake.TryParse(mention.Id);
|
|
||||||
if (mention.Kind == MentionKind.Meta)
|
|
||||||
{
|
|
||||||
_buffer.Append($"@{mention.Id}");
|
|
||||||
}
|
|
||||||
else if (mention.Kind == MentionKind.User)
|
|
||||||
{
|
|
||||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
|
||||||
var name = member?.User.Name ?? "Unknown";
|
|
||||||
|
|
||||||
_buffer.Append($"@{name}");
|
|
||||||
}
|
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
|
||||||
{
|
|
||||||
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
|
||||||
|
|
||||||
_buffer.Append($"#{name}");
|
|
||||||
|
|
||||||
// Voice channel marker
|
|
||||||
if (channel?.IsVoiceChannel == true)
|
|
||||||
_buffer.Append(" [voice]");
|
|
||||||
}
|
|
||||||
else if (mention.Kind == MentionKind.Role)
|
|
||||||
{
|
|
||||||
var role = mentionId?.Pipe(_context.TryGetRole);
|
|
||||||
var name = role?.Name ?? "deleted-role";
|
|
||||||
|
|
||||||
_buffer.Append($"@{name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return base.VisitMention(mention);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
|
||||||
{
|
|
||||||
_buffer.Append(
|
|
||||||
_context.FormatDate(timestamp.Value)
|
|
||||||
);
|
|
||||||
|
|
||||||
return base.VisitUnixTimestamp(timestamp);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class PlainTextMarkdownVisitor
|
protected override MarkdownNode VisitText(TextNode text)
|
||||||
{
|
{
|
||||||
public static string Format(ExportContext context, string markdown)
|
_buffer.Append(text.Text);
|
||||||
|
return base.VisitText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||||
|
{
|
||||||
|
_buffer.Append(
|
||||||
|
emoji.IsCustomEmoji
|
||||||
|
? $":{emoji.Name}:"
|
||||||
|
: emoji.Name
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.VisitEmoji(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||||
|
{
|
||||||
|
var mentionId = Snowflake.TryParse(mention.Id);
|
||||||
|
if (mention.Kind == MentionKind.Meta)
|
||||||
{
|
{
|
||||||
var nodes = MarkdownParser.ParseMinimal(markdown);
|
_buffer.Append($"@{mention.Id}");
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
new PlainTextMarkdownVisitor(context, buffer).Visit(nodes);
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
}
|
||||||
|
else if (mention.Kind == MentionKind.User)
|
||||||
|
{
|
||||||
|
var member = mentionId?.Pipe(_context.TryGetMember);
|
||||||
|
var name = member?.User.Name ?? "Unknown";
|
||||||
|
|
||||||
|
_buffer.Append($"@{name}");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
|
{
|
||||||
|
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
||||||
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
|
_buffer.Append($"#{name}");
|
||||||
|
|
||||||
|
// Voice channel marker
|
||||||
|
if (channel?.IsVoiceChannel == true)
|
||||||
|
_buffer.Append(" [voice]");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Role)
|
||||||
|
{
|
||||||
|
var role = mentionId?.Pipe(_context.TryGetRole);
|
||||||
|
var name = role?.Name ?? "deleted-role";
|
||||||
|
|
||||||
|
_buffer.Append($"@{name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return base.VisitMention(mention);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
||||||
|
{
|
||||||
|
_buffer.Append(
|
||||||
|
_context.FormatDate(timestamp.Value)
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.VisitUnixTimestamp(timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class PlainTextMarkdownVisitor
|
||||||
|
{
|
||||||
|
public static string Format(ExportContext context, string markdown)
|
||||||
|
{
|
||||||
|
var nodes = MarkdownParser.ParseMinimal(markdown);
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
new PlainTextMarkdownVisitor(context, buffer).Visit(nodes);
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4,34 +4,33 @@ using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
namespace DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
|
internal abstract class MessageWriter : IAsyncDisposable
|
||||||
{
|
{
|
||||||
internal abstract class MessageWriter : IAsyncDisposable
|
protected Stream Stream { get; }
|
||||||
|
|
||||||
|
protected ExportContext Context { get; }
|
||||||
|
|
||||||
|
public long MessagesWritten { get; private set; }
|
||||||
|
|
||||||
|
public long BytesWritten => Stream.Length;
|
||||||
|
|
||||||
|
protected MessageWriter(Stream stream, ExportContext context)
|
||||||
{
|
{
|
||||||
protected Stream Stream { get; }
|
Stream = stream;
|
||||||
|
Context = context;
|
||||||
protected ExportContext Context { get; }
|
|
||||||
|
|
||||||
public long MessagesWritten { get; private set; }
|
|
||||||
|
|
||||||
public long BytesWritten => Stream.Length;
|
|
||||||
|
|
||||||
protected MessageWriter(Stream stream, ExportContext context)
|
|
||||||
{
|
|
||||||
Stream = stream;
|
|
||||||
Context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) => default;
|
|
||||||
|
|
||||||
public virtual ValueTask WriteMessageAsync(Message message, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
MessagesWritten++;
|
|
||||||
return default;
|
|
||||||
}
|
|
||||||
|
|
||||||
public virtual ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) => default;
|
|
||||||
|
|
||||||
public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public virtual ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) => default;
|
||||||
|
|
||||||
|
public virtual ValueTask WriteMessageAsync(Message message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
MessagesWritten++;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) => default;
|
||||||
|
|
||||||
|
public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
@ -7,174 +7,173 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
namespace DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
|
internal class PlainTextMessageWriter : MessageWriter
|
||||||
{
|
{
|
||||||
internal class PlainTextMessageWriter : MessageWriter
|
private readonly TextWriter _writer;
|
||||||
|
|
||||||
|
public PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||||
|
: base(stream, context)
|
||||||
{
|
{
|
||||||
private readonly TextWriter _writer;
|
_writer = new StreamWriter(stream);
|
||||||
|
}
|
||||||
|
|
||||||
public PlainTextMessageWriter(Stream stream, ExportContext context)
|
private string FormatMarkdown(string? markdown) =>
|
||||||
: base(stream, context)
|
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||||
|
|
||||||
|
private async ValueTask WriteMessageHeaderAsync(Message message)
|
||||||
|
{
|
||||||
|
// Timestamp & author
|
||||||
|
await _writer.WriteAsync($"[{Context.FormatDate(message.Timestamp)}]");
|
||||||
|
await _writer.WriteAsync($" {message.Author.FullName}");
|
||||||
|
|
||||||
|
// Whether the message is pinned
|
||||||
|
if (message.IsPinned)
|
||||||
|
await _writer.WriteAsync(" (pinned)");
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteAttachmentsAsync(
|
||||||
|
IReadOnlyList<Attachment> attachments,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!attachments.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync("{Attachments}");
|
||||||
|
|
||||||
|
foreach (var attachment in attachments)
|
||||||
{
|
{
|
||||||
_writer = new StreamWriter(stream);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatMarkdown(string? markdown) =>
|
await _writer.WriteLineAsync();
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
}
|
||||||
|
|
||||||
private async ValueTask WriteMessageHeaderAsync(Message message)
|
private async ValueTask WriteEmbedsAsync(
|
||||||
|
IReadOnlyList<Embed> embeds,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
foreach (var embed in embeds)
|
||||||
{
|
{
|
||||||
// Timestamp & author
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
await _writer.WriteAsync($"[{Context.FormatDate(message.Timestamp)}]");
|
|
||||||
await _writer.WriteAsync($" {message.Author.FullName}");
|
|
||||||
|
|
||||||
// Whether the message is pinned
|
await _writer.WriteLineAsync("{Embed}");
|
||||||
if (message.IsPinned)
|
|
||||||
await _writer.WriteAsync(" (pinned)");
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
|
||||||
}
|
await _writer.WriteLineAsync(embed.Author.Name);
|
||||||
|
|
||||||
private async ValueTask WriteAttachmentsAsync(
|
if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
IReadOnlyList<Attachment> attachments,
|
await _writer.WriteLineAsync(embed.Url);
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!attachments.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync("{Attachments}");
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
await _writer.WriteLineAsync(FormatMarkdown(embed.Title));
|
||||||
|
|
||||||
foreach (var attachment in attachments)
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
await _writer.WriteLineAsync(FormatMarkdown(embed.Description));
|
||||||
|
|
||||||
|
foreach (var field in embed.Fields)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
|
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
|
||||||
|
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
|
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
||||||
}
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken));
|
||||||
|
|
||||||
private async ValueTask WriteEmbedsAsync(
|
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
|
||||||
IReadOnlyList<Embed> embeds,
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url, cancellationToken));
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
foreach (var embed in embeds)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync("{Embed}");
|
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||||
|
await _writer.WriteLineAsync(embed.Footer.Text);
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
|
|
||||||
await _writer.WriteLineAsync(embed.Author.Name);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Url))
|
|
||||||
await _writer.WriteLineAsync(embed.Url);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(embed.Title));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(embed.Description));
|
|
||||||
|
|
||||||
foreach (var field in embed.Fields)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(field.Name))
|
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(field.Value))
|
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
|
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url, cancellationToken));
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
|
||||||
await _writer.WriteLineAsync(embed.Footer.Text);
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteReactionsAsync(
|
|
||||||
IReadOnlyList<Reaction> reactions,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
if (!reactions.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync("{Reactions}");
|
|
||||||
|
|
||||||
foreach (var reaction in reactions)
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
|
|
||||||
await _writer.WriteAsync(reaction.Emoji.Name);
|
|
||||||
|
|
||||||
if (reaction.Count > 1)
|
|
||||||
await _writer.WriteAsync($" ({reaction.Count})");
|
|
||||||
|
|
||||||
await _writer.WriteAsync(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
private async ValueTask WriteReactionsAsync(
|
||||||
await _writer.WriteLineAsync(new string('=', 62));
|
IReadOnlyList<Reaction> reactions,
|
||||||
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
|
CancellationToken cancellationToken = default)
|
||||||
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
|
{
|
||||||
|
if (!reactions.Any())
|
||||||
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
return;
|
||||||
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
|
||||||
|
await _writer.WriteLineAsync("{Reactions}");
|
||||||
if (Context.Request.After is not null)
|
|
||||||
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
|
foreach (var reaction in reactions)
|
||||||
|
{
|
||||||
if (Context.Request.Before is not null)
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
|
|
||||||
|
await _writer.WriteAsync(reaction.Emoji.Name);
|
||||||
await _writer.WriteLineAsync(new string('=', 62));
|
|
||||||
await _writer.WriteLineAsync();
|
if (reaction.Count > 1)
|
||||||
}
|
await _writer.WriteAsync($" ({reaction.Count})");
|
||||||
|
|
||||||
public override async ValueTask WriteMessageAsync(
|
await _writer.WriteAsync(' ');
|
||||||
Message message,
|
}
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
await _writer.WriteLineAsync();
|
||||||
await base.WriteMessageAsync(message, cancellationToken);
|
}
|
||||||
|
|
||||||
// Header
|
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
||||||
await WriteMessageHeaderAsync(message);
|
{
|
||||||
|
await _writer.WriteLineAsync(new string('=', 62));
|
||||||
// Content
|
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
|
||||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
|
||||||
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
||||||
|
|
||||||
// Attachments, embeds, reactions
|
if (Context.Request.After is not null)
|
||||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
|
||||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
|
||||||
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
if (Context.Request.Before is not null)
|
||||||
|
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
|
||||||
await _writer.WriteLineAsync();
|
|
||||||
}
|
await _writer.WriteLineAsync(new string('=', 62));
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
}
|
||||||
{
|
|
||||||
await _writer.WriteLineAsync(new string('=', 62));
|
public override async ValueTask WriteMessageAsync(
|
||||||
await _writer.WriteLineAsync($"Exported {MessagesWritten:N0} message(s)");
|
Message message,
|
||||||
await _writer.WriteLineAsync(new string('=', 62));
|
CancellationToken cancellationToken = default)
|
||||||
}
|
{
|
||||||
|
await base.WriteMessageAsync(message, cancellationToken);
|
||||||
public override async ValueTask DisposeAsync()
|
|
||||||
{
|
// Header
|
||||||
await _writer.DisposeAsync();
|
await WriteMessageHeaderAsync(message);
|
||||||
await base.DisposeAsync();
|
|
||||||
}
|
// Content
|
||||||
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
|
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
|
// Attachments, embeds, reactions
|
||||||
|
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||||
|
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||||
|
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync(new string('=', 62));
|
||||||
|
await _writer.WriteLineAsync($"Exported {MessagesWritten:N0} message(s)");
|
||||||
|
await _writer.WriteLineAsync(new string('=', 62));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _writer.DisposeAsync();
|
||||||
|
await base.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,24 +1,23 @@
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
|
internal record EmojiNode(
|
||||||
|
// Only present on custom emoji
|
||||||
|
string? Id,
|
||||||
|
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||||
|
string Name,
|
||||||
|
bool IsAnimated) : MarkdownNode
|
||||||
{
|
{
|
||||||
internal record EmojiNode(
|
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||||
// Only present on custom emoji
|
public string Code => !string.IsNullOrWhiteSpace(Id)
|
||||||
string? Id,
|
? Name
|
||||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||||
string Name,
|
|
||||||
bool IsAnimated) : MarkdownNode
|
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);
|
||||||
|
|
||||||
|
public EmojiNode(string name)
|
||||||
|
: this(null, name, false)
|
||||||
{
|
{
|
||||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
|
||||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
|
||||||
? Name
|
|
||||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
|
||||||
|
|
||||||
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);
|
|
||||||
|
|
||||||
public EmojiNode(string name)
|
|
||||||
: this(null, name, false)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
|
internal enum FormattingKind
|
||||||
{
|
{
|
||||||
internal enum FormattingKind
|
Bold,
|
||||||
{
|
Italic,
|
||||||
Bold,
|
Underline,
|
||||||
Italic,
|
Strikethrough,
|
||||||
Underline,
|
Spoiler,
|
||||||
Strikethrough,
|
Quote
|
||||||
Spoiler,
|
|
||||||
Quote
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
{
|
|
||||||
internal record FormattingNode(FormattingKind Kind, IReadOnlyList<MarkdownNode> Children) : MarkdownNode;
|
internal record FormattingNode(FormattingKind Kind, IReadOnlyList<MarkdownNode> Children) : MarkdownNode;
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
{
|
|
||||||
internal record InlineCodeBlockNode(string Code) : MarkdownNode;
|
internal record InlineCodeBlockNode(string Code) : MarkdownNode;
|
||||||
}
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
|
internal record LinkNode(
|
||||||
|
string Url,
|
||||||
|
IReadOnlyList<MarkdownNode> Children) : MarkdownNode
|
||||||
{
|
{
|
||||||
internal record LinkNode(
|
public LinkNode(string url)
|
||||||
string Url,
|
: this(url, new[] { new TextNode(url) })
|
||||||
IReadOnlyList<MarkdownNode> Children) : MarkdownNode
|
|
||||||
{
|
{
|
||||||
public LinkNode(string url)
|
|
||||||
: this(url, new[] { new TextNode(url) })
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
{
|
|
||||||
internal abstract record MarkdownNode;
|
internal abstract record MarkdownNode;
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue