mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 15:14:37 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1495ed9b90 | |||
| be66c74c08 | |||
| 6f54c09d96 | |||
| 45926b0990 | |||
| 25d29dc079 | |||
| efde9931c9 | |||
| 2c3b461d49 | |||
| b405052fd6 | |||
| da6d903f36 | |||
| e1b1755fba | |||
| 1ff026eba6 | |||
| 1fe4ecb3af | |||
| 47a1518cd9 | |||
| d78c10ebb7 | |||
| 3db29b520c | |||
| 752003abc3 | |||
| e26a0660d1 | |||
| 7dfc3b2723 | |||
| 06c33373de | |||
| 563f5cb67b | |||
| 1f20915b4d | |||
| e58c7aefff | |||
| ac5ccc3fa4 | |||
| 2fc0aa43e8 | |||
| ba66b52fa4 | |||
| ac64d9943a | |||
| 94a85cdb01 |
@@ -1,3 +1,31 @@
|
|||||||
|
### v2.22 (12-Aug-2020)
|
||||||
|
|
||||||
|
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||||
|
- [GUI] Improved exporting options by adding a set of controls that can be used to limit the date range of the export down to minutes. Previously it was only possible to configure the date range without the time component. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||||
|
- [HTML] Fixed an issue where the export didn't reflect changes in nicknames between messages sent by bots. This affected chat logs that contained interactions with the Tupperbox bot. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||||
|
- [CLI] Fixed an issue where the application crashed if there were two environment variables defined that had the same name but in different case.
|
||||||
|
|
||||||
|
### v2.21.2 (30-Jul-2020)
|
||||||
|
|
||||||
|
- [GUI] When copy-pasting token, any surrounding spaces are now discarded, in addition to double quotes.
|
||||||
|
- [HTML] Changed underlying templating engine to provide higher performance and better error messages. New templating engine adds a small cold start, but any export after the first should be faster.
|
||||||
|
- Changed naming schema for downloaded media so that it follows `<original filename> (nn)` format rather than `<original filename>-salt`.
|
||||||
|
- [HTML] Fixed an issue where downloaded media was sometimes inaccessible due to reserved characters appearing in the URL.
|
||||||
|
- Fixed an issue where the application crashed when trying to download media with a file name that exceeds system's maximum allowed length.
|
||||||
|
- Fixed an issue where the application crashed when trying to download media with illegal characters in the file name.
|
||||||
|
|
||||||
|
### v2.21.1 (19-Jul-2020)
|
||||||
|
|
||||||
|
- Fixed an issue where the export crashed if an embedded image has been deleted. Such media files are now ignored.
|
||||||
|
- Changed the naming convention for downloaded media files so that the original file names are used when possible.
|
||||||
|
|
||||||
|
### v2.21 (18-Jul-2020)
|
||||||
|
|
||||||
|
- Added a new option that enables self-contained exports for all output formats. You can turn it on in the export setup dialog in GUI or using the `--media` option in CLI. When using this, the application will additionally download any media content directly referenced from the exported file instead of linking back to Discord CDN. The files which are downloaded include: guild icons, user avatars, attachments, embedded images, reaction emojis. Note that only files which are actually referenced by the export are downloaded, which means that, for example, user avatars will not be downloaded when using plain text export format. This option is not meant to enable complete offline viewing for HTML exports, but rather to make it easier to archive media content that may eventually get deleted from Discord servers. Also keep in mind that this option may make the export drastically slower and the total file size larger.
|
||||||
|
- Changed "discordapp.com" to "discord.com" where applicable as Discord is migrating to a new domain. CDN will remain on "cdn.discordapp.com" for the foreseeable future.
|
||||||
|
|
||||||
|
Note that all existing and current HTML exports will likely not render accurately because Discord enabled CORS for their font resources, which prevents them from loading locally. Please refer to [issue #322](https://github.com/Tyrrrz/DiscordChatExporter/issues/322) for discussion on this topic.
|
||||||
|
|
||||||
### v2.20 (27-Apr-2020)
|
### v2.20 (27-Apr-2020)
|
||||||
|
|
||||||
- [CLI] Added environment variables as fallback for `--token` and `--bot` options. They are `DISCORD_TOKEN` and `DISCORD_TOKEN_BOT` respectively.
|
- [CLI] Added environment variables as fallback for `--token` and `--bot` options. They are `DISCORD_TOKEN` and `DISCORD_TOKEN_BOT` respectively.
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
{
|
{
|
||||||
public abstract class ExportCommandBase : TokenCommandBase
|
public abstract class ExportCommandBase : TokenCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("format", 'f', Description = "Output file format.")]
|
|
||||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
|
||||||
|
|
||||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
||||||
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
|
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
|
[CommandOption("format", 'f', Description = "Output file format.")]
|
||||||
|
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
[CommandOption("after", Description = "Limit to messages sent after this date.")]
|
[CommandOption("after", Description = "Limit to messages sent after this date.")]
|
||||||
public DateTimeOffset? After { get; set; }
|
public DateTimeOffset? After { get; set; }
|
||||||
|
|
||||||
@@ -26,6 +26,9 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
|
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
|
||||||
public int? PartitionLimit { get; set; }
|
public int? PartitionLimit { get; set; }
|
||||||
|
|
||||||
|
[CommandOption("media", Description = "Download referenced media content.")]
|
||||||
|
public bool ShouldDownloadMedia { get; set; }
|
||||||
|
|
||||||
[CommandOption("dateformat", Description = "Date format used in output.")]
|
[CommandOption("dateformat", Description = "Date format used in output.")]
|
||||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
|
||||||
@@ -36,9 +39,19 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
console.Output.Write($"Exporting channel '{channel.Category} / {channel.Name}'... ");
|
console.Output.Write($"Exporting channel '{channel.Category} / {channel.Name}'... ");
|
||||||
var progress = console.CreateProgressTicker();
|
var progress = console.CreateProgressTicker();
|
||||||
|
|
||||||
await GetChannelExporter().ExportAsync(guild, channel,
|
var request = new ExportRequest(
|
||||||
OutputPath, ExportFormat, DateFormat, PartitionLimit,
|
guild,
|
||||||
After, Before, progress);
|
channel,
|
||||||
|
OutputPath,
|
||||||
|
ExportFormat,
|
||||||
|
After,
|
||||||
|
Before,
|
||||||
|
PartitionLimit,
|
||||||
|
ShouldDownloadMedia,
|
||||||
|
DateFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
await GetChannelExporter().ExportChannelAsync(request, progress);
|
||||||
|
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
console.Output.WriteLine("Done.");
|
console.Output.WriteLine("Done.");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Utilities;
|
using CliFx.Utilities;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exceptions;
|
using DiscordChatExporter.Domain.Exceptions;
|
||||||
|
using DiscordChatExporter.Domain.Exporting;
|
||||||
using DiscordChatExporter.Domain.Utilities;
|
using DiscordChatExporter.Domain.Utilities;
|
||||||
using Gress;
|
using Gress;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
@@ -15,13 +16,12 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
{
|
{
|
||||||
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("parallel", Description = "Export this number of separate channels in parallel.")]
|
[CommandOption("parallel", Description = "Export this number of channels in parallel.")]
|
||||||
public int ParallelLimit { get; set; } = 1;
|
public int ParallelLimit { get; set; } = 1;
|
||||||
|
|
||||||
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
// This uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
|
// HACK: this uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
|
||||||
// Ugly code ahead. Will need to refactor.
|
|
||||||
|
|
||||||
console.Output.Write($"Exporting {channels.Count} channels... ");
|
console.Output.Write($"Exporting {channels.Count} channels... ");
|
||||||
var progress = console.CreateProgressTicker();
|
var progress = console.CreateProgressTicker();
|
||||||
@@ -39,9 +39,19 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
{
|
{
|
||||||
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
|
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
|
||||||
|
|
||||||
await GetChannelExporter().ExportAsync(guild, channel,
|
var request = new ExportRequest(
|
||||||
OutputPath, ExportFormat, DateFormat, PartitionLimit,
|
guild,
|
||||||
After, Before, operation);
|
channel,
|
||||||
|
OutputPath,
|
||||||
|
ExportFormat,
|
||||||
|
After,
|
||||||
|
Before,
|
||||||
|
PartitionLimit,
|
||||||
|
ShouldDownloadMedia,
|
||||||
|
DateFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
await GetChannelExporter().ExportChannelAsync(request, operation);
|
||||||
|
|
||||||
Interlocked.Increment(ref successfulExportCount);
|
Interlocked.Increment(ref successfulExportCount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
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. Navigate to the Application tab");
|
console.Output.WriteLine(" 3. Navigate to the Application tab");
|
||||||
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discord.com\" on the left");
|
||||||
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
|
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
|
||||||
console.Output.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
|
console.Output.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
|
||||||
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.");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="1.1.0" />
|
<PackageReference Include="CliFx" Version="1.3.2" />
|
||||||
<PackageReference Include="Gress" Version="1.2.0" />
|
<PackageReference Include="Gress" Version="1.2.0" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
Value = value;
|
Value = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AuthenticationHeaderValue GetAuthorizationHeader() => Type == AuthTokenType.User
|
public AuthenticationHeaderValue GetAuthorizationHeader() => Type switch
|
||||||
? new AuthenticationHeaderValue(Value)
|
{
|
||||||
: new AuthenticationHeaderValue("Bot", Value);
|
AuthTokenType.Bot => new AuthenticationHeaderValue("Bot", Value),
|
||||||
|
_ => new AuthenticationHeaderValue(Value)
|
||||||
|
};
|
||||||
|
|
||||||
public override string ToString() => Value;
|
public override string ToString() => Value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,25 +8,26 @@ using System.Threading.Tasks;
|
|||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exceptions;
|
using DiscordChatExporter.Domain.Exceptions;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal;
|
||||||
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
using Polly;
|
using Polly;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord
|
namespace DiscordChatExporter.Domain.Discord
|
||||||
{
|
{
|
||||||
public partial class DiscordClient
|
public class DiscordClient
|
||||||
{
|
{
|
||||||
private readonly AuthToken _token;
|
private readonly AuthToken _token;
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||||
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
|
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
|
||||||
|
|
||||||
private readonly Uri _baseUri = new Uri("https://discordapp.com/api/v6/", UriKind.Absolute);
|
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
|
||||||
|
|
||||||
public DiscordClient(AuthToken token, HttpClient httpClient)
|
public DiscordClient(AuthToken token)
|
||||||
{
|
{
|
||||||
_token = token;
|
_token = token;
|
||||||
_httpClient = httpClient;
|
|
||||||
|
|
||||||
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
|
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
|
||||||
// For that reason the policy will start respecting their retry-after header only after Nth failed response.
|
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
|
||||||
|
// finally wait the specified amount of time, based on how many requests have failed in a row.
|
||||||
_httpRequestPolicy = Policy
|
_httpRequestPolicy = Policy
|
||||||
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||||
@@ -41,26 +42,19 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
|
|
||||||
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
|
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
|
||||||
},
|
},
|
||||||
(response, timespan, retryCount, context) => Task.CompletedTask);
|
(response, timespan, retryCount, context) => Task.CompletedTask
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public DiscordClient(AuthToken token)
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () =>
|
||||||
: this(token, LazyHttpClient.Value)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HttpResponseMessage> GetResponseAsync(string url)
|
|
||||||
{
|
|
||||||
return await _httpRequestPolicy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
||||||
|
|
||||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<JsonElement> GetJsonResponseAsync(string url)
|
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
|
||||||
{
|
{
|
||||||
using var response = await GetResponseAsync(url);
|
using var response = await GetResponseAsync(url);
|
||||||
|
|
||||||
@@ -78,7 +72,7 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
return await response.Content.ReadAsJsonAsync();
|
return await response.Content.ReadAsJsonAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JsonElement?> TryGetJsonResponseAsync(string url)
|
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(string url)
|
||||||
{
|
{
|
||||||
using var response = await GetResponseAsync(url);
|
using var response = await GetResponseAsync(url);
|
||||||
|
|
||||||
@@ -97,15 +91,14 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
var url = new UrlBuilder()
|
var url = new UrlBuilder()
|
||||||
.SetPath("users/@me/guilds")
|
.SetPath("users/@me/guilds")
|
||||||
.SetQueryParameter("limit", "100")
|
.SetQueryParameter("limit", "100")
|
||||||
.SetQueryParameterIfNotNullOrWhiteSpace("after", afterId)
|
.SetQueryParameter("after", afterId)
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url);
|
var response = await GetJsonResponseAsync(url);
|
||||||
|
|
||||||
var isEmpty = true;
|
var isEmpty = true;
|
||||||
foreach (var guildJson in response.EnumerateArray())
|
foreach (var guild in response.EnumerateArray().Select(Guild.Parse))
|
||||||
{
|
{
|
||||||
var guild = Guild.Parse(guildJson);
|
|
||||||
yield return guild;
|
yield return guild;
|
||||||
|
|
||||||
afterId = guild.Id;
|
afterId = guild.Id;
|
||||||
@@ -117,7 +110,7 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Guild> GetGuildAsync(string guildId)
|
public async ValueTask<Guild> GetGuildAsync(string guildId)
|
||||||
{
|
{
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
return Guild.DirectMessages;
|
return Guild.DirectMessages;
|
||||||
@@ -174,7 +167,7 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
yield return Role.Parse(roleJson);
|
yield return Role.Parse(roleJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Member?> TryGetGuildMemberAsync(string guildId, User user)
|
public async ValueTask<Member?> TryGetGuildMemberAsync(string guildId, User user)
|
||||||
{
|
{
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
return Member.CreateForUser(user);
|
return Member.CreateForUser(user);
|
||||||
@@ -183,13 +176,13 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
return response?.Pipe(Member.Parse);
|
return response?.Pipe(Member.Parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetChannelCategoryAsync(string channelParentId)
|
private async ValueTask<string> GetChannelCategoryAsync(string channelParentId)
|
||||||
{
|
{
|
||||||
var response = await GetJsonResponseAsync($"channels/{channelParentId}");
|
var response = await GetJsonResponseAsync($"channels/{channelParentId}");
|
||||||
return response.GetProperty("name").GetString();
|
return response.GetProperty("name").GetString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Channel> GetChannelAsync(string channelId)
|
public async ValueTask<Channel> GetChannelAsync(string channelId)
|
||||||
{
|
{
|
||||||
var response = await GetJsonResponseAsync($"channels/{channelId}");
|
var response = await GetJsonResponseAsync($"channels/{channelId}");
|
||||||
|
|
||||||
@@ -201,12 +194,12 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
return Channel.Parse(response, category);
|
return Channel.Parse(response, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Message?> TryGetLastMessageAsync(string channelId, DateTimeOffset? before = null)
|
private async ValueTask<Message?> TryGetLastMessageAsync(string channelId, DateTimeOffset? before = null)
|
||||||
{
|
{
|
||||||
var url = new UrlBuilder()
|
var url = new UrlBuilder()
|
||||||
.SetPath($"channels/{channelId}/messages")
|
.SetPath($"channels/{channelId}/messages")
|
||||||
.SetQueryParameter("limit", "1")
|
.SetQueryParameter("limit", "1")
|
||||||
.SetQueryParameterIfNotNullOrWhiteSpace("before", before?.ToSnowflake())
|
.SetQueryParameter("before", before?.ToSnowflake())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url);
|
var response = await GetJsonResponseAsync(url);
|
||||||
@@ -219,11 +212,15 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
DateTimeOffset? before = null,
|
DateTimeOffset? before = null,
|
||||||
IProgress<double>? progress = null)
|
IProgress<double>? progress = null)
|
||||||
{
|
{
|
||||||
// Get the last message in the specified range
|
// Get the last message in the specified range.
|
||||||
|
// This snapshots the boundaries, which means that messages posted after the exported 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);
|
var lastMessage = await TryGetLastMessageAsync(channelId, before);
|
||||||
if (lastMessage == null || lastMessage.Timestamp < after)
|
if (lastMessage == null || lastMessage.Timestamp < after)
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
|
// Keep track of first message in range in order to calculate progress
|
||||||
var firstMessage = default(Message);
|
var firstMessage = default(Message);
|
||||||
var afterId = after?.ToSnowflake() ?? "0";
|
var afterId = after?.ToSnowflake() ?? "0";
|
||||||
|
|
||||||
@@ -267,19 +264,4 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class DiscordClient
|
|
||||||
{
|
|
||||||
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
|
|
||||||
{
|
|
||||||
var handler = new HttpClientHandler();
|
|
||||||
|
|
||||||
if (handler.SupportsAutomaticDecompression)
|
|
||||||
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
|
||||||
|
|
||||||
handler.UseCookies = false;
|
|
||||||
|
|
||||||
return new HttpClient(handler, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,11 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
// https://discord.com/developers/docs/resources/channel#attachment-object
|
||||||
public partial class Attachment : IHasId
|
public partial class Attachment : IHasId
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#channel-object-channel-types
|
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||||
// Order of enum fields needs to match the order in the docs.
|
// Order of enum fields needs to match the order in the docs.
|
||||||
public enum ChannelType
|
public enum ChannelType
|
||||||
{
|
{
|
||||||
@@ -19,7 +19,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
GuildStore
|
GuildStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://discordapp.com/developers/docs/resources/channel#channel-object
|
// https://discord.com/developers/docs/resources/channel#channel-object
|
||||||
public partial class Channel : IHasId
|
public partial class Channel : IHasId
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -85,7 +85,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
guildId ?? Guild.DirectMessages.Id,
|
guildId ?? Guild.DirectMessages.Id,
|
||||||
category ?? GetDefaultCategory(type),
|
category ?? GetDefaultCategory(type),
|
||||||
name,
|
name,
|
||||||
topic);
|
topic
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,11 @@ using System.Collections.Generic;
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#embed-object
|
// https://discord.com/developers/docs/resources/channel#embed-object
|
||||||
public partial class Embed
|
public partial class Embed
|
||||||
{
|
{
|
||||||
public string? Title { get; }
|
public string? Title { get; }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||||
public partial class EmbedAuthor
|
public partial class EmbedAuthor
|
||||||
{
|
{
|
||||||
public string? Name { get; }
|
public string? Name { get; }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||||
public partial class EmbedField
|
public partial class EmbedField
|
||||||
{
|
{
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||||
public partial class EmbedFooter
|
public partial class EmbedFooter
|
||||||
{
|
{
|
||||||
public string Text { get; }
|
public string Text { get; }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||||
public partial class EmbedImage
|
public partial class EmbedImage
|
||||||
{
|
{
|
||||||
public string? Url { get; }
|
public string? Url { get; }
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
|
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
||||||
public partial class Emoji
|
public partial class Emoji
|
||||||
{
|
{
|
||||||
public string? Id { get; }
|
public string? Id { get; }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using DiscordChatExporter.Domain.Discord.Models.Common;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||||
public partial class Guild : IHasId
|
public partial class Guild : IHasId
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -12,12 +12,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
|
|
||||||
public string IconUrl { get; }
|
public string IconUrl { get; }
|
||||||
|
|
||||||
public Guild(string id, string name, string? iconHash)
|
public Guild(string id, string name, string iconUrl)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
|
IconUrl = iconUrl;
|
||||||
IconUrl = GetIconUrl(id, iconHash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
@@ -26,12 +25,13 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
public partial class Guild
|
public partial class Guild
|
||||||
{
|
{
|
||||||
public static Guild DirectMessages { get; } =
|
public static Guild DirectMessages { get; } =
|
||||||
new Guild("@me", "Direct Messages", null);
|
new Guild("@me", "Direct Messages", GetDefaultIconUrl());
|
||||||
|
|
||||||
private static string GetIconUrl(string id, string? iconHash) =>
|
private static string GetDefaultIconUrl() =>
|
||||||
!string.IsNullOrWhiteSpace(iconHash)
|
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||||
? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
|
|
||||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
private static string GetIconUrl(string id, string iconHash) =>
|
||||||
|
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
|
||||||
|
|
||||||
public static Guild Parse(JsonElement json)
|
public static Guild Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
@@ -39,7 +39,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
var name = json.GetProperty("name").GetString();
|
var name = json.GetProperty("name").GetString();
|
||||||
var iconHash = json.GetProperty("icon").GetString();
|
var iconHash = json.GetProperty("icon").GetString();
|
||||||
|
|
||||||
return new Guild(id, name, iconHash);
|
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
||||||
|
? GetIconUrl(id, iconHash)
|
||||||
|
: GetDefaultIconUrl();
|
||||||
|
|
||||||
|
return new Guild(id, name, iconUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,11 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/guild#guild-member-object
|
// https://discord.com/developers/docs/resources/guild#guild-member-object
|
||||||
public partial class Member : IHasId
|
public partial class Member : IHasId
|
||||||
{
|
{
|
||||||
public string Id => User.Id;
|
public string Id => User.Id;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#message-object-message-types
|
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||||
public enum MessageType
|
public enum MessageType
|
||||||
{
|
{
|
||||||
Default,
|
Default,
|
||||||
@@ -20,7 +20,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
GuildMemberJoin
|
GuildMemberJoin
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://discordapp.com/developers/docs/resources/channel#message-object
|
// https://discord.com/developers/docs/resources/channel#message-object
|
||||||
public partial class Message : IHasId
|
public partial class Message : IHasId
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -71,10 +71,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
MentionedUsers = mentionedUsers;
|
MentionedUsers = mentionedUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() =>
|
public override string ToString() => Content;
|
||||||
Content ?? (Embeds.Any()
|
|
||||||
? "<embed>"
|
|
||||||
: "<no content>");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Message
|
public partial class Message
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#reaction-object
|
// https://discord.com/developers/docs/resources/channel#reaction-object
|
||||||
public partial class Reaction
|
public partial class Reaction
|
||||||
{
|
{
|
||||||
public Emoji Emoji { get; }
|
public Emoji Emoji { get; }
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
// https://discord.com/developers/docs/topics/permissions#role-object
|
||||||
public partial class Role
|
public partial class Role
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord.Models
|
namespace DiscordChatExporter.Domain.Discord.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/user#user-object
|
// https://discord.com/developers/docs/resources/user#user-object
|
||||||
public partial class User : IHasId
|
public partial class User : IHasId
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
@@ -20,14 +20,13 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
|
|
||||||
public string AvatarUrl { get; }
|
public string AvatarUrl { get; }
|
||||||
|
|
||||||
public User(string id, bool isBot, int discriminator, string name, string? avatarHash)
|
public User(string id, bool isBot, int discriminator, string name, string avatarUrl)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
IsBot = isBot;
|
IsBot = isBot;
|
||||||
Discriminator = discriminator;
|
Discriminator = discriminator;
|
||||||
Name = name;
|
Name = name;
|
||||||
|
AvatarUrl = avatarUrl;
|
||||||
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => FullName;
|
public override string ToString() => FullName;
|
||||||
@@ -35,10 +34,10 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
|
|
||||||
public partial class User
|
public partial class User
|
||||||
{
|
{
|
||||||
private static string GetAvatarUrl(string id, int discriminator, string? avatarHash)
|
private static string GetDefaultAvatarUrl(int discriminator) =>
|
||||||
{
|
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
||||||
// Custom avatar
|
|
||||||
if (!string.IsNullOrWhiteSpace(avatarHash))
|
private static string GetAvatarUrl(string id, string avatarHash)
|
||||||
{
|
{
|
||||||
// Animated
|
// Animated
|
||||||
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
|
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
|
||||||
@@ -48,10 +47,6 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
|
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default avatar
|
|
||||||
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static User Parse(JsonElement json)
|
public static User Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetString();
|
var id = json.GetProperty("id").GetString();
|
||||||
@@ -60,7 +55,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
var avatarHash = json.GetProperty("avatar").GetString();
|
var avatarHash = json.GetProperty("avatar").GetString();
|
||||||
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
|
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
|
||||||
|
|
||||||
return new User(id, isBot, discriminator, name, avatarHash);
|
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
||||||
|
? GetAvatarUrl(id, avatarHash)
|
||||||
|
: GetDefaultAvatarUrl(discriminator);
|
||||||
|
|
||||||
|
return new User(id, isBot, discriminator, name, avatarUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,17 +2,18 @@
|
|||||||
<Import Project="../DiscordChatExporter.props" />
|
<Import Project="../DiscordChatExporter.props" />
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Polly" Version="7.2.0" />
|
<PackageReference Include="MiniRazor" Version="1.1.0" />
|
||||||
<PackageReference Include="Scriban" Version="2.1.2" />
|
<PackageReference Include="Polly" Version="7.2.1" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<EmbeddedResource Include="Exporting\Resources\HtmlCore.css" />
|
<EmbeddedResource Include="Exporting\Writers\Html\Core.css" />
|
||||||
<EmbeddedResource Include="Exporting\Resources\HtmlDark.css" />
|
<EmbeddedResource Include="Exporting\Writers\Html\Dark.css" />
|
||||||
<EmbeddedResource Include="Exporting\Resources\HtmlLayoutTemplate.html" />
|
<EmbeddedResource Include="Exporting\Writers\Html\Light.css" />
|
||||||
<EmbeddedResource Include="Exporting\Resources\HtmlLight.css" />
|
<EmbeddedResource Include="Exporting\Writers\Html\PreambleTemplate.cshtml" />
|
||||||
<EmbeddedResource Include="Exporting\Resources\HtmlMessageGroupTemplate.html" />
|
<EmbeddedResource Include="Exporting\Writers\Html\PostambleTemplate.cshtml" />
|
||||||
|
<EmbeddedResource Include="Exporting\Writers\Html\MessageGroupTemplate.cshtml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exceptions
|
namespace DiscordChatExporter.Domain.Exceptions
|
||||||
{
|
{
|
||||||
@@ -43,17 +42,14 @@ Failed to perform an HTTP request.
|
|||||||
|
|
||||||
internal static DiscordChatExporterException NotFound()
|
internal static DiscordChatExporterException NotFound()
|
||||||
{
|
{
|
||||||
const string message = "Not found.";
|
const string message = "Requested resource does not exist.";
|
||||||
return new DiscordChatExporterException(message);
|
return new DiscordChatExporterException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static DiscordChatExporterException ChannelEmpty(string channel)
|
internal static DiscordChatExporterException ChannelIsEmpty(string channel)
|
||||||
{
|
{
|
||||||
var message = $"Channel '{channel}' contains no messages for the specified period.";
|
var message = $"Channel '{channel}' contains no messages for the specified period.";
|
||||||
return new DiscordChatExporterException(message);
|
return new DiscordChatExporterException(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static DiscordChatExporterException ChannelEmpty(Channel channel) =>
|
|
||||||
ChannelEmpty(channel.Name);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Discord;
|
using DiscordChatExporter.Domain.Discord;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
@@ -12,7 +10,7 @@ using DiscordChatExporter.Domain.Utilities;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting
|
namespace DiscordChatExporter.Domain.Exporting
|
||||||
{
|
{
|
||||||
public partial class ChannelExporter
|
public class ChannelExporter
|
||||||
{
|
{
|
||||||
private readonly DiscordClient _discord;
|
private readonly DiscordClient _discord;
|
||||||
|
|
||||||
@@ -20,50 +18,39 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
|
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
|
||||||
|
|
||||||
public async Task ExportAsync(
|
public async ValueTask ExportChannelAsync(ExportRequest request, IProgress<double>? progress = null)
|
||||||
Guild guild,
|
|
||||||
Channel channel,
|
|
||||||
string outputPath,
|
|
||||||
ExportFormat format,
|
|
||||||
string dateFormat,
|
|
||||||
int? partitionLimit,
|
|
||||||
DateTimeOffset? after = null,
|
|
||||||
DateTimeOffset? before = null,
|
|
||||||
IProgress<double>? progress = null)
|
|
||||||
{
|
{
|
||||||
var baseFilePath = GetFilePathFromOutputPath(guild, channel, outputPath, format, after, before);
|
// Build context
|
||||||
|
|
||||||
// Options
|
|
||||||
var options = new ExportOptions(baseFilePath, format, partitionLimit);
|
|
||||||
|
|
||||||
// Context
|
|
||||||
var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
|
var contextMembers = new HashSet<Member>(IdBasedEqualityComparer.Instance);
|
||||||
var contextChannels = await _discord.GetGuildChannelsAsync(guild.Id);
|
var contextChannels = await _discord.GetGuildChannelsAsync(request.Guild.Id);
|
||||||
var contextRoles = await _discord.GetGuildRolesAsync(guild.Id);
|
var contextRoles = await _discord.GetGuildRolesAsync(request.Guild.Id);
|
||||||
|
|
||||||
var context = new ExportContext(
|
var context = new ExportContext(
|
||||||
guild, channel, after, before, dateFormat,
|
request,
|
||||||
contextMembers, contextChannels, contextRoles
|
contextMembers,
|
||||||
|
contextChannels,
|
||||||
|
contextRoles
|
||||||
);
|
);
|
||||||
|
|
||||||
await using var messageExporter = new MessageExporter(options, context);
|
// Export messages
|
||||||
|
await using var messageExporter = new MessageExporter(context);
|
||||||
|
|
||||||
var exportedAnything = false;
|
var exportedAnything = false;
|
||||||
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
|
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
|
||||||
await foreach (var message in _discord.GetMessagesAsync(channel.Id, after, before, progress))
|
await foreach (var message in _discord.GetMessagesAsync(request.Channel.Id, request.After, request.Before, progress))
|
||||||
{
|
{
|
||||||
// Resolve members for referenced users
|
// Resolve members for referenced users
|
||||||
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
|
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
|
||||||
{
|
{
|
||||||
if (encounteredUsers.Add(referencedUser))
|
if (!encounteredUsers.Add(referencedUser))
|
||||||
{
|
continue;
|
||||||
|
|
||||||
var member =
|
var member =
|
||||||
await _discord.TryGetGuildMemberAsync(guild.Id, referencedUser) ??
|
await _discord.TryGetGuildMemberAsync(request.Guild.Id, referencedUser) ??
|
||||||
Member.CreateForUser(referencedUser);
|
Member.CreateForUser(referencedUser);
|
||||||
|
|
||||||
contextMembers.Add(member);
|
contextMembers.Add(member);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Export message
|
// Export message
|
||||||
await messageExporter.ExportMessageAsync(message);
|
await messageExporter.ExportMessageAsync(message);
|
||||||
@@ -72,75 +59,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
// Throw if no messages were exported
|
// Throw if no messages were exported
|
||||||
if (!exportedAnything)
|
if (!exportedAnything)
|
||||||
throw DiscordChatExporterException.ChannelEmpty(channel);
|
throw DiscordChatExporterException.ChannelIsEmpty(request.Channel.Name);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class ChannelExporter
|
|
||||||
{
|
|
||||||
public static string GetDefaultExportFileName(
|
|
||||||
Guild guild,
|
|
||||||
Channel channel,
|
|
||||||
ExportFormat format,
|
|
||||||
DateTimeOffset? after = null,
|
|
||||||
DateTimeOffset? before = null)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
// Guild and channel names
|
|
||||||
buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]");
|
|
||||||
|
|
||||||
// Date range
|
|
||||||
if (after != null || before != null)
|
|
||||||
{
|
|
||||||
buffer.Append(" (");
|
|
||||||
|
|
||||||
// Both 'after' and 'before' are set
|
|
||||||
if (after != null && before != null)
|
|
||||||
{
|
|
||||||
buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
|
|
||||||
}
|
|
||||||
// Only 'after' is set
|
|
||||||
else if (after != null)
|
|
||||||
{
|
|
||||||
buffer.Append($"after {after:yyyy-MM-dd}");
|
|
||||||
}
|
|
||||||
// Only 'before' is set
|
|
||||||
else
|
|
||||||
{
|
|
||||||
buffer.Append($"before {before:yyyy-MM-dd}");
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer.Append(")");
|
|
||||||
}
|
|
||||||
|
|
||||||
// File extension
|
|
||||||
buffer.Append($".{format.GetFileExtension()}");
|
|
||||||
|
|
||||||
// Replace invalid chars
|
|
||||||
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
|
||||||
buffer.Replace(invalidChar, '_');
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetFilePathFromOutputPath(
|
|
||||||
Guild guild,
|
|
||||||
Channel channel,
|
|
||||||
string outputPath,
|
|
||||||
ExportFormat format,
|
|
||||||
DateTimeOffset? after = null,
|
|
||||||
DateTimeOffset? before = null)
|
|
||||||
{
|
|
||||||
// Output is a directory
|
|
||||||
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
|
|
||||||
{
|
|
||||||
var fileName = GetDefaultExportFileName(guild, channel, format, after, before);
|
|
||||||
return Path.Combine(outputPath, fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Output is a file
|
|
||||||
return outputPath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting
|
namespace DiscordChatExporter.Domain.Exporting
|
||||||
{
|
{
|
||||||
public class ExportContext
|
internal class ExportContext
|
||||||
{
|
{
|
||||||
public Guild Guild { get; }
|
private readonly MediaDownloader _mediaDownloader;
|
||||||
|
|
||||||
public Channel Channel { get; }
|
public ExportRequest Request { get; }
|
||||||
|
|
||||||
public DateTimeOffset? After { get; }
|
|
||||||
|
|
||||||
public DateTimeOffset? Before { get; }
|
|
||||||
|
|
||||||
public string DateFormat { get; }
|
|
||||||
|
|
||||||
public IReadOnlyCollection<Member> Members { get; }
|
public IReadOnlyCollection<Member> Members { get; }
|
||||||
|
|
||||||
@@ -25,46 +24,71 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
public IReadOnlyCollection<Role> Roles { get; }
|
public IReadOnlyCollection<Role> Roles { get; }
|
||||||
|
|
||||||
public ExportContext(
|
public ExportContext(
|
||||||
Guild guild,
|
ExportRequest request,
|
||||||
Channel channel,
|
|
||||||
DateTimeOffset? after,
|
|
||||||
DateTimeOffset? before,
|
|
||||||
string dateFormat,
|
|
||||||
IReadOnlyCollection<Member> members,
|
IReadOnlyCollection<Member> members,
|
||||||
IReadOnlyCollection<Channel> channels,
|
IReadOnlyCollection<Channel> channels,
|
||||||
IReadOnlyCollection<Role> roles)
|
IReadOnlyCollection<Role> roles)
|
||||||
{
|
{
|
||||||
Guild = guild;
|
Request = request;
|
||||||
Channel = channel;
|
|
||||||
After = after;
|
|
||||||
Before = before;
|
|
||||||
DateFormat = dateFormat;
|
|
||||||
Members = members;
|
Members = members;
|
||||||
Channels = channels;
|
Channels = channels;
|
||||||
Roles = roles;
|
Roles = roles;
|
||||||
|
|
||||||
|
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Member? TryGetMentionedMember(string id) =>
|
public string FormatDate(DateTimeOffset date) => date.ToLocalString(Request.DateFormat);
|
||||||
|
|
||||||
|
public Member? TryGetMember(string id) =>
|
||||||
Members.FirstOrDefault(m => m.Id == id);
|
Members.FirstOrDefault(m => m.Id == id);
|
||||||
|
|
||||||
public Channel? TryGetMentionedChannel(string id) =>
|
public Channel? TryGetChannel(string id) =>
|
||||||
Channels.FirstOrDefault(c => c.Id == id);
|
Channels.FirstOrDefault(c => c.Id == id);
|
||||||
|
|
||||||
public Role? TryGetMentionedRole(string id) =>
|
public Role? TryGetRole(string id) =>
|
||||||
Roles.FirstOrDefault(r => r.Id == id);
|
Roles.FirstOrDefault(r => r.Id == id);
|
||||||
|
|
||||||
public Member? TryGetUserMember(User user) => Members
|
public Color? TryGetUserColor(string id)
|
||||||
.FirstOrDefault(m => m.Id == user.Id);
|
|
||||||
|
|
||||||
public Color? TryGetUserColor(User user)
|
|
||||||
{
|
{
|
||||||
var member = TryGetUserMember(user);
|
var member = TryGetMember(id);
|
||||||
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
||||||
|
|
||||||
return roles?
|
return roles?
|
||||||
|
.Where(r => r.Color != null)
|
||||||
.OrderByDescending(r => r.Position)
|
.OrderByDescending(r => r.Position)
|
||||||
.Select(r => r.Color)
|
.Select(r => r.Color)
|
||||||
.FirstOrDefault(c => c != null);
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> ResolveMediaUrlAsync(string url)
|
||||||
|
{
|
||||||
|
if (!Request.ShouldDownloadMedia)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var filePath = await _mediaDownloader.DownloadAsync(url);
|
||||||
|
|
||||||
|
// We want relative path so that the output files can be copied around without breaking
|
||||||
|
var relativeFilePath = Path.GetRelativePath(Request.OutputBaseDirPath, filePath);
|
||||||
|
|
||||||
|
// For HTML, we need to format the URL properly
|
||||||
|
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
|
||||||
|
{
|
||||||
|
// Need to escape each path segment while keeping the directory separators intact
|
||||||
|
return relativeFilePath
|
||||||
|
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||||
|
.Select(Uri.EscapeDataString)
|
||||||
|
.JoinToString(Path.AltDirectorySeparatorChar.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return relativeFilePath;
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
// We don't want this to crash the exporting process in case of failure
|
||||||
|
return url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace DiscordChatExporter.Domain.Exporting
|
|
||||||
{
|
|
||||||
public class ExportOptions
|
|
||||||
{
|
|
||||||
public string BaseFilePath { get; }
|
|
||||||
|
|
||||||
public ExportFormat Format { get; }
|
|
||||||
|
|
||||||
public int? PartitionLimit { get; }
|
|
||||||
|
|
||||||
public ExportOptions(string baseFilePath, ExportFormat format, int? partitionLimit)
|
|
||||||
{
|
|
||||||
BaseFilePath = baseFilePath;
|
|
||||||
Format = format;
|
|
||||||
PartitionLimit = partitionLimit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
|
using DiscordChatExporter.Domain.Internal;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Exporting
|
||||||
|
{
|
||||||
|
public partial class ExportRequest
|
||||||
|
{
|
||||||
|
public Guild Guild { get; }
|
||||||
|
|
||||||
|
public Channel Channel { get; }
|
||||||
|
|
||||||
|
public string OutputPath { get; }
|
||||||
|
|
||||||
|
public string OutputBaseFilePath { get; }
|
||||||
|
|
||||||
|
public string OutputBaseDirPath { get; }
|
||||||
|
|
||||||
|
public string OutputMediaDirPath { get; }
|
||||||
|
|
||||||
|
public ExportFormat Format { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset? After { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset? Before { get; }
|
||||||
|
|
||||||
|
public int? PartitionLimit { get; }
|
||||||
|
|
||||||
|
public bool ShouldDownloadMedia { get; }
|
||||||
|
|
||||||
|
public string DateFormat { get; }
|
||||||
|
|
||||||
|
public ExportRequest(
|
||||||
|
Guild guild,
|
||||||
|
Channel channel,
|
||||||
|
string outputPath,
|
||||||
|
ExportFormat format,
|
||||||
|
DateTimeOffset? after,
|
||||||
|
DateTimeOffset? before,
|
||||||
|
int? partitionLimit,
|
||||||
|
bool shouldDownloadMedia,
|
||||||
|
string dateFormat)
|
||||||
|
{
|
||||||
|
Guild = guild;
|
||||||
|
Channel = channel;
|
||||||
|
OutputPath = outputPath;
|
||||||
|
Format = format;
|
||||||
|
After = after;
|
||||||
|
Before = before;
|
||||||
|
PartitionLimit = partitionLimit;
|
||||||
|
ShouldDownloadMedia = shouldDownloadMedia;
|
||||||
|
DateFormat = dateFormat;
|
||||||
|
|
||||||
|
OutputBaseFilePath = GetOutputBaseFilePath(
|
||||||
|
guild,
|
||||||
|
channel,
|
||||||
|
outputPath,
|
||||||
|
format,
|
||||||
|
after,
|
||||||
|
before
|
||||||
|
);
|
||||||
|
|
||||||
|
OutputBaseDirPath = Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
|
||||||
|
OutputMediaDirPath = $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class ExportRequest
|
||||||
|
{
|
||||||
|
private static string GetOutputBaseFilePath(
|
||||||
|
Guild guild,
|
||||||
|
Channel channel,
|
||||||
|
string outputPath,
|
||||||
|
ExportFormat format,
|
||||||
|
DateTimeOffset? after = null,
|
||||||
|
DateTimeOffset? before = null)
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output is a file
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDefaultOutputFileName(
|
||||||
|
Guild guild,
|
||||||
|
Channel channel,
|
||||||
|
ExportFormat format,
|
||||||
|
DateTimeOffset? after = null,
|
||||||
|
DateTimeOffset? before = null)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
// Guild and channel names
|
||||||
|
buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]");
|
||||||
|
|
||||||
|
// Date range
|
||||||
|
if (after != null || before != null)
|
||||||
|
{
|
||||||
|
buffer.Append(" (");
|
||||||
|
|
||||||
|
// Both 'after' and 'before' are set
|
||||||
|
if (after != null && before != null)
|
||||||
|
{
|
||||||
|
buffer.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
// Only 'after' is set
|
||||||
|
else if (after != null)
|
||||||
|
{
|
||||||
|
buffer.Append($"after {after:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
// Only 'before' is set
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buffer.Append($"before {before:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.Append(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// File extension
|
||||||
|
buffer.Append($".{format.GetFileExtension()}");
|
||||||
|
|
||||||
|
// Replace invalid chars
|
||||||
|
PathEx.EscapePath(buffer);
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Domain.Internal;
|
||||||
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Exporting
|
||||||
|
{
|
||||||
|
internal partial class MediaDownloader
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||||
|
private readonly string _workingDirPath;
|
||||||
|
|
||||||
|
private readonly Dictionary<string, string> _pathMap = new Dictionary<string, string>();
|
||||||
|
|
||||||
|
public MediaDownloader(string workingDirPath)
|
||||||
|
{
|
||||||
|
_workingDirPath = workingDirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> DownloadAsync(string url)
|
||||||
|
{
|
||||||
|
if (_pathMap.TryGetValue(url, out var cachedFilePath))
|
||||||
|
return cachedFilePath;
|
||||||
|
|
||||||
|
var fileName = GetFileNameFromUrl(url);
|
||||||
|
var filePath = PathEx.MakeUniqueFilePath(Path.Combine(_workingDirPath, fileName));
|
||||||
|
|
||||||
|
Directory.CreateDirectory(_workingDirPath);
|
||||||
|
|
||||||
|
await _httpClient.DownloadAsync(url, filePath);
|
||||||
|
|
||||||
|
return _pathMap[url] = filePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class MediaDownloader
|
||||||
|
{
|
||||||
|
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
|
||||||
|
|
||||||
|
private static string GetFileNameFromUrl(string url)
|
||||||
|
{
|
||||||
|
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||||
|
|
||||||
|
var fileName = !string.IsNullOrWhiteSpace(originalFileName)
|
||||||
|
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(50)}{Path.GetExtension(originalFileName)}"
|
||||||
|
: GetRandomFileName();
|
||||||
|
|
||||||
|
return PathEx.EscapePath(fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,26 +8,24 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
{
|
{
|
||||||
internal partial class MessageExporter : IAsyncDisposable
|
internal partial class MessageExporter : IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly ExportOptions _options;
|
|
||||||
private readonly ExportContext _context;
|
private readonly ExportContext _context;
|
||||||
|
|
||||||
private long _renderedMessageCount;
|
private long _messageCount;
|
||||||
private int _partitionIndex;
|
private int _partitionIndex;
|
||||||
private MessageWriter? _writer;
|
private MessageWriter? _writer;
|
||||||
|
|
||||||
public MessageExporter(ExportOptions options, ExportContext context)
|
public MessageExporter(ExportContext context)
|
||||||
{
|
{
|
||||||
_options = options;
|
|
||||||
_context = context;
|
_context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsPartitionLimitReached() =>
|
private bool IsPartitionLimitReached() =>
|
||||||
_renderedMessageCount > 0 &&
|
_messageCount > 0 &&
|
||||||
_options.PartitionLimit != null &&
|
_context.Request.PartitionLimit != null &&
|
||||||
_options.PartitionLimit != 0 &&
|
_context.Request.PartitionLimit != 0 &&
|
||||||
_renderedMessageCount % _options.PartitionLimit == 0;
|
_messageCount % _context.Request.PartitionLimit == 0;
|
||||||
|
|
||||||
private async Task ResetWriterAsync()
|
private async ValueTask ResetWriterAsync()
|
||||||
{
|
{
|
||||||
if (_writer != null)
|
if (_writer != null)
|
||||||
{
|
{
|
||||||
@@ -37,7 +35,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<MessageWriter> GetWriterAsync()
|
private async ValueTask<MessageWriter> GetWriterAsync()
|
||||||
{
|
{
|
||||||
// Ensure partition limit has not been exceeded
|
// Ensure partition limit has not been exceeded
|
||||||
if (IsPartitionLimitReached())
|
if (IsPartitionLimitReached())
|
||||||
@@ -50,23 +48,23 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
if (_writer != null)
|
if (_writer != null)
|
||||||
return _writer;
|
return _writer;
|
||||||
|
|
||||||
var filePath = GetPartitionFilePath(_options.BaseFilePath, _partitionIndex);
|
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex);
|
||||||
|
|
||||||
var dirPath = Path.GetDirectoryName(_options.BaseFilePath);
|
var dirPath = Path.GetDirectoryName(_context.Request.OutputBaseFilePath);
|
||||||
if (!string.IsNullOrWhiteSpace(dirPath))
|
if (!string.IsNullOrWhiteSpace(dirPath))
|
||||||
Directory.CreateDirectory(dirPath);
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
var writer = CreateMessageWriter(filePath, _options.Format, _context);
|
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context);
|
||||||
await writer.WritePreambleAsync();
|
await writer.WritePreambleAsync();
|
||||||
|
|
||||||
return _writer = writer;
|
return _writer = writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ExportMessageAsync(Message message)
|
public async ValueTask ExportMessageAsync(Message message)
|
||||||
{
|
{
|
||||||
var writer = await GetWriterAsync();
|
var writer = await GetWriterAsync();
|
||||||
await writer.WriteMessageAsync(message);
|
await writer.WriteMessageAsync(message);
|
||||||
_renderedMessageCount++;
|
_messageCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
{{~ # Metadata ~}}
|
|
||||||
<title>{{ Context.Guild.Name | html.escape }} - {{ Context.Channel.Name | html.escape }}</title>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width">
|
|
||||||
|
|
||||||
{{~ # Styles ~}}
|
|
||||||
<style>
|
|
||||||
{{ CoreStyleSheet }}
|
|
||||||
</style>
|
|
||||||
<style>
|
|
||||||
{{ ThemeStyleSheet }}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
{{~ # Syntax highlighting ~}}
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{{~ # Local scripts ~}}
|
|
||||||
<script>
|
|
||||||
function scrollToMessage(event, id) {
|
|
||||||
var element = document.getElementById('message-' + id);
|
|
||||||
|
|
||||||
if (element) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
element.classList.add('chatlog__message--highlighted');
|
|
||||||
|
|
||||||
window.scrollTo({
|
|
||||||
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
|
|
||||||
window.setTimeout(function() {
|
|
||||||
element.classList.remove('chatlog__message--highlighted');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSpoiler(event, element) {
|
|
||||||
if (element && element.classList.contains('spoiler--hidden')) {
|
|
||||||
event.preventDefault();
|
|
||||||
element.classList.remove('spoiler--hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
{{~ # Preamble ~}}
|
|
||||||
<div class="preamble">
|
|
||||||
<div class="preamble__guild-icon-container">
|
|
||||||
<img class="preamble__guild-icon" src="{{ Context.Guild.IconUrl }}" alt="Guild icon">
|
|
||||||
</div>
|
|
||||||
<div class="preamble__entries-container">
|
|
||||||
<div class="preamble__entry">{{ Context.Guild.Name | html.escape }}</div>
|
|
||||||
<div class="preamble__entry">{{ Context.Channel.Category | html.escape }} / {{ Context.Channel.Name | html.escape }}</div>
|
|
||||||
|
|
||||||
{{~ if Context.Channel.Topic ~}}
|
|
||||||
<div class="preamble__entry preamble__entry--small">{{ Context.Channel.Topic | html.escape }}</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ if Context.After || Context.Before ~}}
|
|
||||||
<div class="preamble__entry preamble__entry--small">
|
|
||||||
{{~ if Context.After && Context.Before ~}}
|
|
||||||
Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }}
|
|
||||||
{{~ else if Context.After ~}}
|
|
||||||
After {{ Context.After | FormatDate | html.escape }}
|
|
||||||
{{~ else if Context.Before ~}}
|
|
||||||
Before {{ Context.Before | FormatDate | html.escape }}
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{~ # Log ~}}
|
|
||||||
<div class="chatlog">
|
|
||||||
{{~ %SPLIT% ~}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{~ # Postamble ~}}
|
|
||||||
<div class="postamble">
|
|
||||||
<div class="postamble__entry">Exported {{ MessageCount | object.format "N0" }} message(s)</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<div class="chatlog__message-group">
|
|
||||||
{{~ # Avatar ~}}
|
|
||||||
<div class="chatlog__author-avatar-container">
|
|
||||||
<img class="chatlog__author-avatar" src="{{ MessageGroup.Author.AvatarUrl }}" alt="Avatar">
|
|
||||||
</div>
|
|
||||||
<div class="chatlog__messages">
|
|
||||||
{{~ # Author name and timestamp ~}}
|
|
||||||
{{~ userColor = TryGetUserColor MessageGroup.Author | FormatColorRgb ~}}
|
|
||||||
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if userColor }} style="color: {{ userColor }}" {{ end }}>{{ (TryGetUserNick MessageGroup.Author ?? MessageGroup.Author.Name) | html.escape }}</span>
|
|
||||||
|
|
||||||
{{~ # Bot tag ~}}
|
|
||||||
{{~ if MessageGroup.Author.IsBot ~}}
|
|
||||||
<span class="chatlog__bot-tag">BOT</span>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
<span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span>
|
|
||||||
|
|
||||||
{{~ # Messages ~}}
|
|
||||||
{{~ for message in MessageGroup.Messages ~}}
|
|
||||||
<div class="chatlog__message {{ if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
|
|
||||||
{{~ # Content ~}}
|
|
||||||
{{~ if message.Content ~}}
|
|
||||||
<div class="chatlog__content">
|
|
||||||
<div class="markdown">
|
|
||||||
{{- message.Content | FormatMarkdown -}}
|
|
||||||
|
|
||||||
{{- # Edited timestamp -}}
|
|
||||||
{{- if message.EditedTimestamp -}}
|
|
||||||
{{-}}<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>{{-}}
|
|
||||||
{{- end -}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ # Attachments ~}}
|
|
||||||
{{~ for attachment in message.Attachments ~}}
|
|
||||||
<div class="chatlog__attachment">
|
|
||||||
{{ # Spoiler image }}
|
|
||||||
{{~ if attachment.IsSpoiler ~}}
|
|
||||||
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
|
|
||||||
<div class="spoiler-image">
|
|
||||||
<a href="{{ attachment.Url }}">
|
|
||||||
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" alt="Attachment">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{~ else ~}}
|
|
||||||
<a href="{{ attachment.Url }}">
|
|
||||||
{{ # Non-spoiler image }}
|
|
||||||
{{~ if attachment.IsImage ~}}
|
|
||||||
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" alt="Attachment">
|
|
||||||
{{~ # Non-image ~}}
|
|
||||||
{{~ else ~}}
|
|
||||||
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
|
|
||||||
{{~ end ~}}
|
|
||||||
</a>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ # Embeds ~}}
|
|
||||||
{{~ for embed in message.Embeds ~}}
|
|
||||||
<div class="chatlog__embed">
|
|
||||||
{{~ if embed.Color ~}}
|
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
|
|
||||||
{{~ else ~}}
|
|
||||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
|
||||||
{{~ end ~}}
|
|
||||||
<div class="chatlog__embed-content-container">
|
|
||||||
<div class="chatlog__embed-content">
|
|
||||||
<div class="chatlog__embed-text">
|
|
||||||
{{~ # Author ~}}
|
|
||||||
{{~ if embed.Author ~}}
|
|
||||||
<div class="chatlog__embed-author">
|
|
||||||
{{~ if embed.Author.IconUrl ~}}
|
|
||||||
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" alt="Author icon">
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ if embed.Author.Name ~}}
|
|
||||||
<span class="chatlog__embed-author-name">
|
|
||||||
{{~ if embed.Author.Url ~}}
|
|
||||||
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
|
|
||||||
{{~ else ~}}
|
|
||||||
{{ embed.Author.Name | html.escape }}
|
|
||||||
{{~ end ~}}
|
|
||||||
</span>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ # Title ~}}
|
|
||||||
{{~ if embed.Title ~}}
|
|
||||||
<div class="chatlog__embed-title">
|
|
||||||
{{~ if embed.Url ~}}
|
|
||||||
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div></a>
|
|
||||||
{{~ else ~}}
|
|
||||||
<div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ # Description ~}}
|
|
||||||
{{~ if embed.Description ~}}
|
|
||||||
<div class="chatlog__embed-description"><div class="markdown">{{ embed.Description | FormatEmbedMarkdown }}</div></div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ # Fields ~}}
|
|
||||||
{{~ if embed.Fields | array.size > 0 ~}}
|
|
||||||
<div class="chatlog__embed-fields">
|
|
||||||
{{~ for field in embed.Fields ~}}
|
|
||||||
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
|
||||||
{{~ if field.Name ~}}
|
|
||||||
<div class="chatlog__embed-field-name"><div class="markdown">{{ field.Name | FormatEmbedMarkdown }}</div></div>
|
|
||||||
{{~ end ~}}
|
|
||||||
{{~ if field.Value ~}}
|
|
||||||
<div class="chatlog__embed-field-value"><div class="markdown">{{ field.Value | FormatEmbedMarkdown }}</div></div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{~ # Thumbnail ~}}
|
|
||||||
{{~ if embed.Thumbnail ~}}
|
|
||||||
<div class="chatlog__embed-thumbnail-container">
|
|
||||||
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
|
|
||||||
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" alt="Thumbnail">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{~ # Image ~}}
|
|
||||||
{{~ if embed.Image ~}}
|
|
||||||
<div class="chatlog__embed-image-container">
|
|
||||||
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
|
|
||||||
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" alt="Image">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ # Footer ~}}
|
|
||||||
{{~ if embed.Footer || embed.Timestamp ~}}
|
|
||||||
<div class="chatlog__embed-footer">
|
|
||||||
{{~ if embed.Footer ~}}
|
|
||||||
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
|
|
||||||
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" alt="Footer icon">
|
|
||||||
{{~ end ~}}
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
<span class="chatlog__embed-footer-text">
|
|
||||||
{{~ if embed.Footer ~}}
|
|
||||||
{{~ if embed.Footer.Text ~}}
|
|
||||||
{{ embed.Footer.Text | html.escape }}
|
|
||||||
{{ if embed.Timestamp }} • {{ end }}
|
|
||||||
{{~ end ~}}
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ if embed.Timestamp ~}}
|
|
||||||
{{ embed.Timestamp | FormatDate | html.escape }}
|
|
||||||
{{~ end ~}}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ # Reactions ~}}
|
|
||||||
{{~ if message.Reactions | array.size > 0 ~}}
|
|
||||||
<div class="chatlog__reactions">
|
|
||||||
{{~ for reaction in message.Reactions ~}}
|
|
||||||
<div class="chatlog__reaction">
|
|
||||||
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}">
|
|
||||||
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
{{~ end ~}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using System.IO;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||||
{
|
{
|
||||||
@@ -22,22 +21,68 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
private string FormatMarkdown(string? markdown) =>
|
private string FormatMarkdown(string? markdown) =>
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||||
|
|
||||||
public override async Task WritePreambleAsync() =>
|
public override async ValueTask WritePreambleAsync() =>
|
||||||
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
||||||
|
|
||||||
public override async Task WriteMessageAsync(Message message)
|
private async ValueTask WriteAttachmentsAsync(IReadOnlyList<Attachment> attachments)
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var attachment in attachments)
|
||||||
|
{
|
||||||
buffer
|
buffer
|
||||||
.Append(CsvEncode(message.Author.Id)).Append(',')
|
.AppendIfNotEmpty(',')
|
||||||
.Append(CsvEncode(message.Author.FullName)).Append(',')
|
.Append(await Context.ResolveMediaUrlAsync(attachment.Url));
|
||||||
.Append(CsvEncode(message.Timestamp.ToLocalString(Context.DateFormat))).Append(',')
|
}
|
||||||
.Append(CsvEncode(FormatMarkdown(message.Content))).Append(',')
|
|
||||||
.Append(CsvEncode(message.Attachments.Select(a => a.Url).JoinToString(","))).Append(',')
|
|
||||||
.Append(CsvEncode(message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(",")));
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync(buffer.ToString());
|
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteReactionsAsync(IReadOnlyList<Reaction> reactions)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var reaction in reactions)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
// Author ID
|
||||||
|
await _writer.WriteAsync(CsvEncode(message.Author.Id));
|
||||||
|
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);
|
||||||
|
await _writer.WriteAsync(',');
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
await WriteReactionsAsync(message.Reactions);
|
||||||
|
|
||||||
|
// Finish row
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
|
|||||||
+5
-5
@@ -2,31 +2,31 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discordapp.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
|
src: url(https://discord.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discordapp.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
|
src: url(https://discord.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discordapp.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
|
src: url(https://discord.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discordapp.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
|
src: url(https://discord.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discordapp.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
|
src: url(https://discord.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||||
|
{
|
||||||
|
internal class LayoutTemplateContext
|
||||||
|
{
|
||||||
|
public ExportContext ExportContext { get; }
|
||||||
|
|
||||||
|
public string ThemeName { get; }
|
||||||
|
|
||||||
|
public long MessageCount { get; }
|
||||||
|
|
||||||
|
public LayoutTemplateContext(ExportContext exportContext, string themeName, long messageCount)
|
||||||
|
{
|
||||||
|
ExportContext = exportContext;
|
||||||
|
ThemeName = themeName;
|
||||||
|
MessageCount = messageCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-2
@@ -1,8 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting
|
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||||
{
|
{
|
||||||
// Used for grouping contiguous messages in HTML export
|
// Used for grouping contiguous messages in HTML export
|
||||||
internal partial class MessageGroup
|
internal partial class MessageGroup
|
||||||
@@ -23,9 +24,20 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
internal partial class MessageGroup
|
internal partial class MessageGroup
|
||||||
{
|
{
|
||||||
public static bool CanGroup(Message message1, Message message2) =>
|
public static bool CanJoin(Message message1, Message message2) =>
|
||||||
string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal) &&
|
string.Equals(message1.Author.Id, message2.Author.Id, StringComparison.Ordinal) &&
|
||||||
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
||||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7;
|
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7;
|
||||||
|
|
||||||
|
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
||||||
|
{
|
||||||
|
var first = messages.First();
|
||||||
|
|
||||||
|
return new MessageGroup(
|
||||||
|
first.Author,
|
||||||
|
first.Timestamp,
|
||||||
|
messages
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
@using System
|
||||||
|
@using System.Linq
|
||||||
|
@using System.Threading.Tasks
|
||||||
|
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.MessageGroupTemplateContext>
|
||||||
|
|
||||||
|
@{
|
||||||
|
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||||
|
|
||||||
|
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||||
|
|
||||||
|
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
||||||
|
|
||||||
|
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||||
|
|
||||||
|
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
||||||
|
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
||||||
|
var userNick = (Model.MessageGroup.Author.IsBot ? Model.MessageGroup.Author.Name : (userMember?.Nick ?? Model.MessageGroup.Author.Name));
|
||||||
|
|
||||||
|
var userColorStyle = userColor != null
|
||||||
|
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="chatlog__message-group">
|
||||||
|
<div class="chatlog__author-avatar-container">
|
||||||
|
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar">
|
||||||
|
</div>
|
||||||
|
<div class="chatlog__messages">
|
||||||
|
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
|
||||||
|
|
||||||
|
@if (Model.MessageGroup.Author.IsBot)
|
||||||
|
{
|
||||||
|
<span class="chatlog__bot-tag">BOT</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
|
||||||
|
|
||||||
|
@foreach (var message in Model.MessageGroup.Messages)
|
||||||
|
{
|
||||||
|
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
|
||||||
|
|
||||||
|
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
|
||||||
|
<div class="chatlog__content">
|
||||||
|
<div class="markdown">@Raw(FormatMarkdown(message.Content)) @if (message.EditedTimestamp != null) {<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach (var attachment in message.Attachments)
|
||||||
|
{
|
||||||
|
<div class="chatlog__attachment">
|
||||||
|
@if (attachment.IsSpoiler)
|
||||||
|
{
|
||||||
|
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
|
||||||
|
<div class="spoiler-image">
|
||||||
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
|
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
|
@if (attachment.IsImage)
|
||||||
|
{
|
||||||
|
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
|
||||||
|
}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var embed in message.Embeds)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed">
|
||||||
|
@if (embed.Color != null)
|
||||||
|
{
|
||||||
|
var embedColorStyle = $"background-color: rgba({embed.Color?.R},{embed.Color?.G},{embed.Color?.B},{embed.Color?.A})";
|
||||||
|
<div class="chatlog__embed-color-pill" style="@embedColorStyle"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="chatlog__embed-content-container">
|
||||||
|
<div class="chatlog__embed-content">
|
||||||
|
<div class="chatlog__embed-text">
|
||||||
|
@if (embed.Author != null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-author">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
|
{
|
||||||
|
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconUrl)" alt="Author icon">
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
|
{
|
||||||
|
<span class="chatlog__embed-author-name">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||||
|
{
|
||||||
|
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@embed.Author.Name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-title">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
|
{
|
||||||
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
|
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-description">
|
||||||
|
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (embed.Fields.Any())
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-fields">
|
||||||
|
@foreach (var field in embed.Fields)
|
||||||
|
{
|
||||||
|
var isInlineStyle = field.IsInline ? "chatlog__embed-field--inline" : null;
|
||||||
|
|
||||||
|
<div class="chatlog__embed-field @isInlineStyle">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-field-name">
|
||||||
|
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-field-value">
|
||||||
|
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (embed.Thumbnail != null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-thumbnail-container">
|
||||||
|
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.Url)">
|
||||||
|
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.Url)" alt="Thumbnail">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (embed.Image != null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-image-container">
|
||||||
|
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.Url)">
|
||||||
|
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.Url)" alt="Image">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (embed.Footer != null || embed.Timestamp != null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-footer">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||||
|
{
|
||||||
|
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="chatlog__embed-footer-text">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||||
|
{
|
||||||
|
@embed.Footer.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
||||||
|
{
|
||||||
|
@(" • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (embed.Timestamp != null)
|
||||||
|
{
|
||||||
|
@FormatDate(embed.Timestamp.Value)
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (message.Reactions.Any())
|
||||||
|
{
|
||||||
|
<div class="chatlog__reactions">
|
||||||
|
@foreach (var reaction in message.Reactions)
|
||||||
|
{
|
||||||
|
<div class="chatlog__reaction">
|
||||||
|
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" title="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
|
||||||
|
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||||
|
{
|
||||||
|
internal class MessageGroupTemplateContext
|
||||||
|
{
|
||||||
|
public ExportContext ExportContext { get; }
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="postamble">
|
||||||
|
<div class="postamble__entry">Exported @Model.MessageCount.ToString("N0") message(s)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
@using System
|
||||||
|
@using System.Threading.Tasks
|
||||||
|
@using Tyrrrz.Extensions
|
||||||
|
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||||
|
|
||||||
|
@{
|
||||||
|
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||||
|
|
||||||
|
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||||
|
|
||||||
|
string GetStyleSheet(string name) => Model.GetType().Assembly.GetManifestResourceString($"DiscordChatExporter.Domain.Exporting.Writers.Html.{name}.css");
|
||||||
|
}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<title>@Model.ExportContext.Request.Guild.Name - @Model.ExportContext.Request.Channel.Name</title>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@Raw(GetStyleSheet("Core"))
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
@Raw(GetStyleSheet(Model.ThemeName))
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function scrollToMessage(event, id) {
|
||||||
|
var element = document.getElementById('message-' + id);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
element.classList.add('chatlog__message--highlighted');
|
||||||
|
|
||||||
|
window.scrollTo({
|
||||||
|
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
|
||||||
|
window.setTimeout(function() {
|
||||||
|
element.classList.remove('chatlog__message--highlighted');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSpoiler(event, element) {
|
||||||
|
if (element && element.classList.contains('spoiler--hidden')) {
|
||||||
|
event.preventDefault();
|
||||||
|
element.classList.remove('spoiler--hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="preamble">
|
||||||
|
<div class="preamble__guild-icon-container">
|
||||||
|
<img class="preamble__guild-icon" src="@await ResolveUrlAsync(Model.ExportContext.Request.Guild.IconUrl)" alt="Guild icon">
|
||||||
|
</div>
|
||||||
|
<div class="preamble__entries-container">
|
||||||
|
<div class="preamble__entry">@Model.ExportContext.Request.Guild.Name</div>
|
||||||
|
<div class="preamble__entry">@Model.ExportContext.Request.Channel.Category / @Model.ExportContext.Request.Channel.Name</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic))
|
||||||
|
{
|
||||||
|
<div class="preamble__entry preamble__entry--small">@Model.ExportContext.Request.Channel.Topic</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.ExportContext.Request.After != null || Model.ExportContext.Request.Before != null)
|
||||||
|
{
|
||||||
|
<div class="preamble__entry preamble__entry--small">
|
||||||
|
@if (Model.ExportContext.Request.After != null && Model.ExportContext.Request.Before != null)
|
||||||
|
{
|
||||||
|
@($"Between {FormatDate(Model.ExportContext.Request.After.Value)} and {FormatDate(Model.ExportContext.Request.Before.Value)}")
|
||||||
|
}
|
||||||
|
else if (Model.ExportContext.Request.After != null)
|
||||||
|
{
|
||||||
|
@($"After {FormatDate(Model.ExportContext.Request.After.Value)}")
|
||||||
|
}
|
||||||
|
else if (Model.ExportContext.Request.Before != null)
|
||||||
|
{
|
||||||
|
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value)}")
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chatlog">
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MiniRazor;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PreambleTemplateAssemblyName)]
|
||||||
|
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.MessageGroupTemplateAssemblyName)]
|
||||||
|
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PostambleTemplateAssemblyName)]
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||||
|
{
|
||||||
|
internal partial class TemplateBundle
|
||||||
|
{
|
||||||
|
public const string PreambleTemplateAssemblyName = "RazorAssembly_Preamble";
|
||||||
|
public const string MessageGroupTemplateAssemblyName = "RazorAssembly_MessageGroup";
|
||||||
|
public const string PostambleTemplateAssemblyName = "RazorAssembly_Postamble";
|
||||||
|
|
||||||
|
public MiniRazorTemplateDescriptor PreambleTemplate { get; }
|
||||||
|
|
||||||
|
public MiniRazorTemplateDescriptor MessageGroupTemplate { get; }
|
||||||
|
|
||||||
|
public MiniRazorTemplateDescriptor PostambleTemplate { get; }
|
||||||
|
|
||||||
|
public TemplateBundle(
|
||||||
|
MiniRazorTemplateDescriptor preambleTemplate,
|
||||||
|
MiniRazorTemplateDescriptor messageGroupTemplate,
|
||||||
|
MiniRazorTemplateDescriptor postambleTemplate)
|
||||||
|
{
|
||||||
|
PreambleTemplate = preambleTemplate;
|
||||||
|
MessageGroupTemplate = messageGroupTemplate;
|
||||||
|
PostambleTemplate = postambleTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal partial class TemplateBundle
|
||||||
|
{
|
||||||
|
private static TemplateBundle? _lastBundle;
|
||||||
|
|
||||||
|
// This is very CPU-heavy
|
||||||
|
private static async ValueTask<TemplateBundle> CompileAsync() => await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var ns = typeof(TemplateBundle).Namespace!;
|
||||||
|
|
||||||
|
var preambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||||
|
.GetManifestResourceString($"{ns}.PreambleTemplate.cshtml");
|
||||||
|
|
||||||
|
var messageGroupTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||||
|
.GetManifestResourceString($"{ns}.MessageGroupTemplate.cshtml");
|
||||||
|
|
||||||
|
var postambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||||
|
.GetManifestResourceString($"{ns}.PostambleTemplate.cshtml");
|
||||||
|
|
||||||
|
var engine = new MiniRazorTemplateEngine();
|
||||||
|
|
||||||
|
var preambleTemplate = engine.Compile(preambleTemplateSource, PreambleTemplateAssemblyName, ns);
|
||||||
|
var messageGroupTemplate = engine.Compile(messageGroupTemplateSource, MessageGroupTemplateAssemblyName, ns);
|
||||||
|
var postambleTemplate = engine.Compile(postambleTemplateSource, PostambleTemplateAssemblyName, ns);
|
||||||
|
|
||||||
|
return new TemplateBundle(preambleTemplate, messageGroupTemplate, postambleTemplate);
|
||||||
|
});
|
||||||
|
|
||||||
|
public static async ValueTask<TemplateBundle> ResolveAsync() =>
|
||||||
|
_lastBundle ??= await CompileAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,18 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Domain.Exporting.Writers.Html;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
|
||||||
using Scriban;
|
|
||||||
using Scriban.Runtime;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||||
{
|
{
|
||||||
internal partial class HtmlMessageWriter : MessageWriter
|
internal class HtmlMessageWriter : MessageWriter
|
||||||
{
|
{
|
||||||
private readonly TextWriter _writer;
|
private readonly TextWriter _writer;
|
||||||
private readonly string _themeName;
|
private readonly string _themeName;
|
||||||
private readonly List<Message> _messageGroupBuffer = new List<Message>();
|
|
||||||
|
|
||||||
private readonly Template _preambleTemplate;
|
private readonly List<Message> _messageGroupBuffer = new List<Message>();
|
||||||
private readonly Template _messageGroupTemplate;
|
|
||||||
private readonly Template _postambleTemplate;
|
|
||||||
|
|
||||||
private long _messageCount;
|
private long _messageCount;
|
||||||
|
|
||||||
@@ -31,103 +21,39 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
{
|
{
|
||||||
_writer = new StreamWriter(stream);
|
_writer = new StreamWriter(stream);
|
||||||
_themeName = themeName;
|
_themeName = themeName;
|
||||||
|
|
||||||
_preambleTemplate = Template.Parse(GetPreambleTemplateCode());
|
|
||||||
_messageGroupTemplate = Template.Parse(GetMessageGroupTemplateCode());
|
|
||||||
_postambleTemplate = Template.Parse(GetPostambleTemplateCode());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private MessageGroup GetCurrentMessageGroup()
|
public override async ValueTask WritePreambleAsync()
|
||||||
{
|
{
|
||||||
var firstMessage = _messageGroupBuffer.First();
|
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||||
return new MessageGroup(firstMessage.Author, firstMessage.Timestamp, _messageGroupBuffer);
|
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await templateBundle.PreambleTemplate.RenderAsync(templateContext)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TemplateContext CreateTemplateContext(IReadOnlyDictionary<string, object>? constants = null)
|
private async ValueTask WriteMessageGroupAsync(MessageGroup messageGroup)
|
||||||
{
|
{
|
||||||
// Template context
|
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
||||||
var templateContext = new TemplateContext
|
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||||
{
|
|
||||||
MemberRenamer = m => m.Name,
|
|
||||||
MemberFilter = m => true,
|
|
||||||
LoopLimit = int.MaxValue,
|
|
||||||
StrictVariables = true
|
|
||||||
};
|
|
||||||
|
|
||||||
// Model
|
await _writer.WriteLineAsync(
|
||||||
var scriptObject = new ScriptObject();
|
await templateBundle.MessageGroupTemplate.RenderAsync(templateContext)
|
||||||
|
);
|
||||||
// Constants
|
|
||||||
scriptObject.SetValue("Context", Context, true);
|
|
||||||
scriptObject.SetValue("CoreStyleSheet", GetCoreStyleSheetCode(), true);
|
|
||||||
scriptObject.SetValue("ThemeStyleSheet", GetThemeStyleSheetCode(_themeName), true);
|
|
||||||
scriptObject.SetValue("HighlightJsStyleName", $"solarized-{_themeName.ToLowerInvariant()}", true);
|
|
||||||
|
|
||||||
// Additional constants
|
|
||||||
if (constants != null)
|
|
||||||
{
|
|
||||||
foreach (var (member, value) in constants)
|
|
||||||
scriptObject.SetValue(member, value, true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Functions
|
public override async ValueTask WriteMessageAsync(Message message)
|
||||||
scriptObject.Import("FormatDate",
|
|
||||||
new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.DateFormat)));
|
|
||||||
|
|
||||||
scriptObject.Import("FormatColorRgb",
|
|
||||||
new Func<Color?, string?>(c => c != null ? $"rgb({c?.R}, {c?.G}, {c?.B})" : null));
|
|
||||||
|
|
||||||
scriptObject.Import("TryGetUserColor",
|
|
||||||
new Func<User, Color?>(Context.TryGetUserColor));
|
|
||||||
|
|
||||||
scriptObject.Import("TryGetUserNick",
|
|
||||||
new Func<User, string?>(u => Context.TryGetUserMember(u)?.Nick));
|
|
||||||
|
|
||||||
scriptObject.Import("FormatMarkdown",
|
|
||||||
new Func<string?, string>(m => FormatMarkdown(m)));
|
|
||||||
|
|
||||||
scriptObject.Import("FormatEmbedMarkdown",
|
|
||||||
new Func<string?, string>(m => FormatMarkdown(m, false)));
|
|
||||||
|
|
||||||
// Push model
|
|
||||||
templateContext.PushGlobal(scriptObject);
|
|
||||||
|
|
||||||
// Push output
|
|
||||||
templateContext.PushOutput(new TextWriterOutput(_writer));
|
|
||||||
|
|
||||||
return templateContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
|
||||||
HtmlMarkdownVisitor.Format(Context, markdown ?? "", isJumboAllowed);
|
|
||||||
|
|
||||||
private async Task RenderCurrentMessageGroupAsync()
|
|
||||||
{
|
|
||||||
var templateContext = CreateTemplateContext(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["MessageGroup"] = GetCurrentMessageGroup()
|
|
||||||
});
|
|
||||||
|
|
||||||
await templateContext.EvaluateAsync(_messageGroupTemplate.Page);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task WritePreambleAsync()
|
|
||||||
{
|
|
||||||
var templateContext = CreateTemplateContext();
|
|
||||||
await templateContext.EvaluateAsync(_preambleTemplate.Page);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task WriteMessageAsync(Message message)
|
|
||||||
{
|
{
|
||||||
// If message group is empty or the given message can be grouped, buffer the given message
|
// If message group is empty or the given message can be grouped, buffer the given message
|
||||||
if (!_messageGroupBuffer.Any() || MessageGroup.CanGroup(_messageGroupBuffer.Last(), message))
|
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
|
||||||
{
|
{
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroupBuffer.Add(message);
|
||||||
}
|
}
|
||||||
// Otherwise, flush the group and render messages
|
// Otherwise, flush the group and render messages
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await RenderCurrentMessageGroupAsync();
|
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
|
||||||
|
|
||||||
_messageGroupBuffer.Clear();
|
_messageGroupBuffer.Clear();
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroupBuffer.Add(message);
|
||||||
@@ -137,18 +63,18 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_messageCount++;
|
_messageCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task WritePostambleAsync()
|
public override async ValueTask WritePostambleAsync()
|
||||||
{
|
{
|
||||||
// Flush current message group
|
// Flush current message group
|
||||||
if (_messageGroupBuffer.Any())
|
if (_messageGroupBuffer.Any())
|
||||||
await RenderCurrentMessageGroupAsync();
|
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
|
||||||
|
|
||||||
var templateContext = CreateTemplateContext(new Dictionary<string, object>
|
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||||
{
|
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||||
["MessageCount"] = _messageCount
|
|
||||||
});
|
|
||||||
|
|
||||||
await templateContext.EvaluateAsync(_postambleTemplate.Page);
|
await _writer.WriteLineAsync(
|
||||||
|
await templateBundle.PostambleTemplate.RenderAsync(templateContext)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
@@ -157,32 +83,4 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
await base.DisposeAsync();
|
await base.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class HtmlMessageWriter
|
|
||||||
{
|
|
||||||
private static readonly Assembly ResourcesAssembly = typeof(HtmlMessageWriter).Assembly;
|
|
||||||
private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Exporting.Resources";
|
|
||||||
|
|
||||||
private static string GetCoreStyleSheetCode() =>
|
|
||||||
ResourcesAssembly
|
|
||||||
.GetManifestResourceString($"{ResourcesNamespace}.HtmlCore.css");
|
|
||||||
|
|
||||||
private static string GetThemeStyleSheetCode(string themeName) =>
|
|
||||||
ResourcesAssembly
|
|
||||||
.GetManifestResourceString($"{ResourcesNamespace}.Html{themeName}.css");
|
|
||||||
|
|
||||||
private static string GetPreambleTemplateCode() =>
|
|
||||||
ResourcesAssembly
|
|
||||||
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
|
|
||||||
.SubstringUntil("{{~ %SPLIT% ~}}");
|
|
||||||
|
|
||||||
private static string GetMessageGroupTemplateCode() =>
|
|
||||||
ResourcesAssembly
|
|
||||||
.GetManifestResourceString($"{ResourcesNamespace}.HtmlMessageGroupTemplate.html");
|
|
||||||
|
|
||||||
private static string GetPostambleTemplateCode() =>
|
|
||||||
ResourcesAssembly
|
|
||||||
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
|
|
||||||
.SubstringAfter("{{~ %SPLIT% ~}}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ using System.Text.Json;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||||
{
|
{
|
||||||
@@ -25,62 +25,75 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
private string FormatMarkdown(string? markdown) =>
|
private string FormatMarkdown(string? markdown) =>
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||||
|
|
||||||
private void WriteAttachment(Attachment attachment)
|
private async ValueTask WriteAttachmentAsync(Attachment attachment)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("id", attachment.Id);
|
_writer.WriteString("id", attachment.Id);
|
||||||
_writer.WriteString("url", attachment.Url);
|
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url));
|
||||||
_writer.WriteString("fileName", attachment.FileName);
|
_writer.WriteString("fileName", attachment.FileName);
|
||||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEmbedAuthor(EmbedAuthor embedAuthor)
|
private async ValueTask WriteEmbedAuthorAsync(EmbedAuthor embedAuthor)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("author");
|
_writer.WriteStartObject("author");
|
||||||
|
|
||||||
_writer.WriteString("name", embedAuthor.Name);
|
_writer.WriteString("name", embedAuthor.Name);
|
||||||
_writer.WriteString("url", embedAuthor.Url);
|
_writer.WriteString("url", embedAuthor.Url);
|
||||||
_writer.WriteString("iconUrl", embedAuthor.IconUrl);
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
|
||||||
|
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconUrl));
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEmbedThumbnail(EmbedImage embedThumbnail)
|
private async ValueTask WriteEmbedThumbnailAsync(EmbedImage embedThumbnail)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("thumbnail");
|
_writer.WriteStartObject("thumbnail");
|
||||||
|
|
||||||
_writer.WriteString("url", embedThumbnail.Url);
|
if (!string.IsNullOrWhiteSpace(embedThumbnail.Url))
|
||||||
|
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.Url));
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedThumbnail.Width);
|
_writer.WriteNumber("width", embedThumbnail.Width);
|
||||||
_writer.WriteNumber("height", embedThumbnail.Height);
|
_writer.WriteNumber("height", embedThumbnail.Height);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEmbedImage(EmbedImage embedImage)
|
private async ValueTask WriteEmbedImageAsync(EmbedImage embedImage)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("image");
|
_writer.WriteStartObject("image");
|
||||||
|
|
||||||
_writer.WriteString("url", embedImage.Url);
|
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
||||||
|
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.Url));
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedImage.Width);
|
_writer.WriteNumber("width", embedImage.Width);
|
||||||
_writer.WriteNumber("height", embedImage.Height);
|
_writer.WriteNumber("height", embedImage.Height);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEmbedFooter(EmbedFooter embedFooter)
|
private async ValueTask WriteEmbedFooterAsync(EmbedFooter embedFooter)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("footer");
|
_writer.WriteStartObject("footer");
|
||||||
|
|
||||||
_writer.WriteString("text", embedFooter.Text);
|
_writer.WriteString("text", embedFooter.Text);
|
||||||
_writer.WriteString("iconUrl", embedFooter.IconUrl);
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
|
||||||
|
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconUrl));
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEmbedField(EmbedField embedField)
|
private async ValueTask WriteEmbedFieldAsync(EmbedField embedField)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
@@ -89,9 +102,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteBoolean("isInline", embedField.IsInline);
|
_writer.WriteBoolean("isInline", embedField.IsInline);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteEmbed(Embed embed)
|
private async ValueTask WriteEmbedAsync(Embed embed)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
@@ -101,29 +115,30 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
||||||
|
|
||||||
if (embed.Author != null)
|
if (embed.Author != null)
|
||||||
WriteEmbedAuthor(embed.Author);
|
await WriteEmbedAuthorAsync(embed.Author);
|
||||||
|
|
||||||
if (embed.Thumbnail != null)
|
if (embed.Thumbnail != null)
|
||||||
WriteEmbedThumbnail(embed.Thumbnail);
|
await WriteEmbedThumbnailAsync(embed.Thumbnail);
|
||||||
|
|
||||||
if (embed.Image != null)
|
if (embed.Image != null)
|
||||||
WriteEmbedImage(embed.Image);
|
await WriteEmbedImageAsync(embed.Image);
|
||||||
|
|
||||||
if (embed.Footer != null)
|
if (embed.Footer != null)
|
||||||
WriteEmbedFooter(embed.Footer);
|
await WriteEmbedFooterAsync(embed.Footer);
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
_writer.WriteStartArray("fields");
|
_writer.WriteStartArray("fields");
|
||||||
|
|
||||||
foreach (var field in embed.Fields)
|
foreach (var field in embed.Fields)
|
||||||
WriteEmbedField(field);
|
await WriteEmbedFieldAsync(field);
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void WriteReaction(Reaction reaction)
|
private async ValueTask WriteReactionAsync(Reaction reaction)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
@@ -132,48 +147,48 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteString("id", reaction.Emoji.Id);
|
_writer.WriteString("id", reaction.Emoji.Id);
|
||||||
_writer.WriteString("name", reaction.Emoji.Name);
|
_writer.WriteString("name", reaction.Emoji.Name);
|
||||||
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
||||||
_writer.WriteString("imageUrl", reaction.Emoji.ImageUrl);
|
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl));
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
_writer.WriteNumber("count", reaction.Count);
|
_writer.WriteNumber("count", reaction.Count);
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task WritePreambleAsync()
|
public override async ValueTask WritePreambleAsync()
|
||||||
{
|
{
|
||||||
// Root object (start)
|
// Root object (start)
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
// Guild
|
// Guild
|
||||||
_writer.WriteStartObject("guild");
|
_writer.WriteStartObject("guild");
|
||||||
_writer.WriteString("id", Context.Guild.Id);
|
_writer.WriteString("id", Context.Request.Guild.Id);
|
||||||
_writer.WriteString("name", Context.Guild.Name);
|
_writer.WriteString("name", Context.Request.Guild.Name);
|
||||||
_writer.WriteString("iconUrl", Context.Guild.IconUrl);
|
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl));
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
// Channel
|
// Channel
|
||||||
_writer.WriteStartObject("channel");
|
_writer.WriteStartObject("channel");
|
||||||
_writer.WriteString("id", Context.Channel.Id);
|
_writer.WriteString("id", Context.Request.Channel.Id);
|
||||||
_writer.WriteString("type", Context.Channel.Type.ToString());
|
_writer.WriteString("type", Context.Request.Channel.Type.ToString());
|
||||||
_writer.WriteString("category", Context.Channel.Category);
|
_writer.WriteString("category", Context.Request.Channel.Category);
|
||||||
_writer.WriteString("name", Context.Channel.Name);
|
_writer.WriteString("name", Context.Request.Channel.Name);
|
||||||
_writer.WriteString("topic", Context.Channel.Topic);
|
_writer.WriteString("topic", Context.Request.Channel.Topic);
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
// Date range
|
// Date range
|
||||||
_writer.WriteStartObject("dateRange");
|
_writer.WriteStartObject("dateRange");
|
||||||
_writer.WriteString("after", Context.After);
|
_writer.WriteString("after", Context.Request.After);
|
||||||
_writer.WriteString("before", Context.Before);
|
_writer.WriteString("before", Context.Request.Before);
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
// Message array (start)
|
// Message array (start)
|
||||||
_writer.WriteStartArray("messages");
|
_writer.WriteStartArray("messages");
|
||||||
|
|
||||||
await _writer.FlushAsync();
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task WriteMessageAsync(Message message)
|
public override async ValueTask WriteMessageAsync(Message message)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject();
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
@@ -193,14 +208,14 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteString("name", message.Author.Name);
|
_writer.WriteString("name", message.Author.Name);
|
||||||
_writer.WriteString("discriminator", $"{message.Author.Discriminator:0000}");
|
_writer.WriteString("discriminator", $"{message.Author.Discriminator:0000}");
|
||||||
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
||||||
_writer.WriteString("avatarUrl", message.Author.AvatarUrl);
|
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl));
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
_writer.WriteStartArray("attachments");
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
WriteAttachment(attachment);
|
await WriteAttachmentAsync(attachment);
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -208,7 +223,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteStartArray("embeds");
|
_writer.WriteStartArray("embeds");
|
||||||
|
|
||||||
foreach (var embed in message.Embeds)
|
foreach (var embed in message.Embeds)
|
||||||
WriteEmbed(embed);
|
await WriteEmbedAsync(embed);
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -216,18 +231,17 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteStartArray("reactions");
|
_writer.WriteStartArray("reactions");
|
||||||
|
|
||||||
foreach (var reaction in message.Reactions)
|
foreach (var reaction in message.Reactions)
|
||||||
WriteReaction(reaction);
|
await WriteReactionAsync(reaction);
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
// Flush every 100 messages
|
|
||||||
if (_messageCount++ % 100 == 0)
|
|
||||||
await _writer.FlushAsync();
|
await _writer.FlushAsync();
|
||||||
|
|
||||||
|
_messageCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task WritePostambleAsync()
|
public override async ValueTask WritePostambleAsync()
|
||||||
{
|
{
|
||||||
// Message array (end)
|
// Message array (end)
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
@@ -236,7 +250,6 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
|
|
||||||
// Root object (end)
|
// Root object (end)
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
await _writer.FlushAsync();
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-12
@@ -4,7 +4,6 @@ using System.Net;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
|
||||||
using DiscordChatExporter.Domain.Markdown;
|
using DiscordChatExporter.Domain.Markdown;
|
||||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||||
|
|
||||||
@@ -23,13 +22,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
_isJumbo = isJumbo;
|
_isJumbo = isJumbo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitText(TextNode text)
|
protected override MarkdownNode VisitText(TextNode text)
|
||||||
{
|
{
|
||||||
_buffer.Append(HtmlEncode(text.Text));
|
_buffer.Append(HtmlEncode(text.Text));
|
||||||
return base.VisitText(text);
|
return base.VisitText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitFormatted(FormattedNode formatted)
|
protected override MarkdownNode VisitFormatted(FormattedNode formatted)
|
||||||
{
|
{
|
||||||
var (tagOpen, tagClose) = formatted.Formatting switch
|
var (tagOpen, tagClose) = formatted.Formatting switch
|
||||||
{
|
{
|
||||||
@@ -50,7 +49,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"pre pre--inline\">")
|
.Append("<span class=\"pre pre--inline\">")
|
||||||
@@ -60,7 +59,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
|
protected override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
|
||||||
{
|
{
|
||||||
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
||||||
? $"language-{multiLineCodeBlock.Language}"
|
? $"language-{multiLineCodeBlock.Language}"
|
||||||
@@ -74,7 +73,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitMention(MentionNode mention)
|
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||||
{
|
{
|
||||||
if (mention.Type == MentionType.Meta)
|
if (mention.Type == MentionType.Meta)
|
||||||
{
|
{
|
||||||
@@ -85,7 +84,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
}
|
}
|
||||||
else if (mention.Type == MentionType.User)
|
else if (mention.Type == MentionType.User)
|
||||||
{
|
{
|
||||||
var member = _context.TryGetMentionedMember(mention.Id);
|
var member = _context.TryGetMember(mention.Id);
|
||||||
var fullName = member?.User.FullName ?? "Unknown";
|
var fullName = member?.User.FullName ?? "Unknown";
|
||||||
var nick = member?.Nick ?? "Unknown";
|
var nick = member?.Nick ?? "Unknown";
|
||||||
|
|
||||||
@@ -96,7 +95,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
}
|
}
|
||||||
else if (mention.Type == MentionType.Channel)
|
else if (mention.Type == MentionType.Channel)
|
||||||
{
|
{
|
||||||
var channel = _context.TryGetMentionedChannel(mention.Id);
|
var channel = _context.TryGetChannel(mention.Id);
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
@@ -106,7 +105,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
}
|
}
|
||||||
else if (mention.Type == MentionType.Role)
|
else if (mention.Type == MentionType.Role)
|
||||||
{
|
{
|
||||||
var role = _context.TryGetMentionedRole(mention.Id);
|
var role = _context.TryGetRole(mention.Id);
|
||||||
var name = role?.Name ?? "deleted-role";
|
var name = role?.Name ?? "deleted-role";
|
||||||
var color = role?.Color;
|
var color = role?.Color;
|
||||||
|
|
||||||
@@ -123,7 +122,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
return base.VisitMention(mention);
|
return base.VisitMention(mention);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitEmoji(EmojiNode emoji)
|
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||||
{
|
{
|
||||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||||
var jumboClass = _isJumbo ? "emoji--large" : "";
|
var jumboClass = _isJumbo ? "emoji--large" : "";
|
||||||
@@ -134,10 +133,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
return base.VisitEmoji(emoji);
|
return base.VisitEmoji(emoji);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitLink(LinkNode link)
|
protected override MarkdownNode VisitLink(LinkNode link)
|
||||||
{
|
{
|
||||||
// Extract message ID if the link points to a Discord message
|
// Extract message ID if the link points to a Discord message
|
||||||
var linkedMessageId = Regex.Match(link.Url, "^https?://discordapp.com/channels/.*?/(\\d+)/?$").Groups[1].Value;
|
var linkedMessageId = Regex.Match(link.Url, "^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$").Groups[1].Value;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(linkedMessageId))
|
if (!string.IsNullOrWhiteSpace(linkedMessageId))
|
||||||
{
|
{
|
||||||
|
|||||||
+6
-6
@@ -15,13 +15,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
_buffer = buffer;
|
_buffer = buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitText(TextNode text)
|
protected override MarkdownNode VisitText(TextNode text)
|
||||||
{
|
{
|
||||||
_buffer.Append(text.Text);
|
_buffer.Append(text.Text);
|
||||||
return base.VisitText(text);
|
return base.VisitText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitMention(MentionNode mention)
|
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||||
{
|
{
|
||||||
if (mention.Type == MentionType.Meta)
|
if (mention.Type == MentionType.Meta)
|
||||||
{
|
{
|
||||||
@@ -29,21 +29,21 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
}
|
}
|
||||||
else if (mention.Type == MentionType.User)
|
else if (mention.Type == MentionType.User)
|
||||||
{
|
{
|
||||||
var member = _context.TryGetMentionedMember(mention.Id);
|
var member = _context.TryGetMember(mention.Id);
|
||||||
var name = member?.User.Name ?? "Unknown";
|
var name = member?.User.Name ?? "Unknown";
|
||||||
|
|
||||||
_buffer.Append($"@{name}");
|
_buffer.Append($"@{name}");
|
||||||
}
|
}
|
||||||
else if (mention.Type == MentionType.Channel)
|
else if (mention.Type == MentionType.Channel)
|
||||||
{
|
{
|
||||||
var channel = _context.TryGetMentionedChannel(mention.Id);
|
var channel = _context.TryGetChannel(mention.Id);
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer.Append($"#{name}");
|
_buffer.Append($"#{name}");
|
||||||
}
|
}
|
||||||
else if (mention.Type == MentionType.Role)
|
else if (mention.Type == MentionType.Role)
|
||||||
{
|
{
|
||||||
var role = _context.TryGetMentionedRole(mention.Id);
|
var role = _context.TryGetRole(mention.Id);
|
||||||
var name = role?.Name ?? "deleted-role";
|
var name = role?.Name ?? "deleted-role";
|
||||||
|
|
||||||
_buffer.Append($"@{name}");
|
_buffer.Append($"@{name}");
|
||||||
@@ -52,7 +52,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
|||||||
return base.VisitMention(mention);
|
return base.VisitMention(mention);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override MarkdownNode VisitEmoji(EmojiNode emoji)
|
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||||
{
|
{
|
||||||
_buffer.Append(
|
_buffer.Append(
|
||||||
emoji.IsCustomEmoji
|
emoji.IsCustomEmoji
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
Context = context;
|
Context = context;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual Task WritePreambleAsync() => Task.CompletedTask;
|
public virtual ValueTask WritePreambleAsync() => default;
|
||||||
|
|
||||||
public abstract Task WriteMessageAsync(Message message);
|
public abstract ValueTask WriteMessageAsync(Message message);
|
||||||
|
|
||||||
public virtual Task WritePostambleAsync() => Task.CompletedTask;
|
public virtual ValueTask WritePostambleAsync() => default;
|
||||||
|
|
||||||
public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync();
|
public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
using System;
|
using System.Collections.Generic;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||||
{
|
{
|
||||||
@@ -25,151 +23,134 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
private string FormatMarkdown(string? markdown) =>
|
private string FormatMarkdown(string? markdown) =>
|
||||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||||
|
|
||||||
private string FormatMessageHeader(Message message)
|
private async ValueTask WriteMessageHeaderAsync(Message message)
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
// Timestamp & author
|
// Timestamp & author
|
||||||
buffer
|
await _writer.WriteAsync($"[{Context.FormatDate(message.Timestamp)}]");
|
||||||
.Append($"[{message.Timestamp.ToLocalString(Context.DateFormat)}]")
|
await _writer.WriteAsync($" {message.Author.FullName}");
|
||||||
.Append(' ')
|
|
||||||
.Append($"{message.Author.FullName}");
|
|
||||||
|
|
||||||
// Whether the message is pinned
|
// Whether the message is pinned
|
||||||
if (message.IsPinned)
|
if (message.IsPinned)
|
||||||
buffer.Append(' ').Append("(pinned)");
|
await _writer.WriteAsync(" (pinned)");
|
||||||
|
|
||||||
return buffer.ToString();
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatAttachments(IReadOnlyList<Attachment> attachments)
|
private async ValueTask WriteAttachmentsAsync(IReadOnlyList<Attachment> attachments)
|
||||||
{
|
{
|
||||||
if (!attachments.Any())
|
if (!attachments.Any())
|
||||||
return "";
|
return;
|
||||||
|
|
||||||
var buffer = new StringBuilder();
|
await _writer.WriteLineAsync("{Attachments}");
|
||||||
|
|
||||||
buffer
|
foreach (var attachment in attachments)
|
||||||
.AppendLine("{Attachments}")
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url));
|
||||||
.AppendJoin(Environment.NewLine, attachments.Select(a => a.Url))
|
|
||||||
.AppendLine();
|
|
||||||
|
|
||||||
return buffer.ToString();
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatEmbeds(IReadOnlyList<Embed> embeds)
|
private async ValueTask WriteEmbedsAsync(IReadOnlyList<Embed> embeds)
|
||||||
{
|
{
|
||||||
if (!embeds.Any())
|
|
||||||
return "";
|
|
||||||
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var embed in embeds)
|
foreach (var embed in embeds)
|
||||||
{
|
{
|
||||||
buffer
|
await _writer.WriteLineAsync("{Embed}");
|
||||||
.AppendLine("{Embed}")
|
|
||||||
.AppendLineIfNotNullOrWhiteSpace(embed.Author?.Name)
|
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
|
||||||
.AppendLineIfNotNullOrWhiteSpace(embed.Url)
|
await _writer.WriteLineAsync(embed.Author.Name);
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(embed.Title))
|
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(embed.Description));
|
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)
|
foreach (var field in embed.Fields)
|
||||||
{
|
{
|
||||||
buffer
|
if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Name))
|
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(field.Value));
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
|
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer
|
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
||||||
.AppendLineIfNotNullOrWhiteSpace(embed.Thumbnail?.Url)
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.Url));
|
||||||
.AppendLineIfNotNullOrWhiteSpace(embed.Image?.Url)
|
|
||||||
.AppendLineIfNotNullOrWhiteSpace(embed.Footer?.Text)
|
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
|
||||||
.AppendLine();
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.Url));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||||
|
await _writer.WriteLineAsync(embed.Footer.Text);
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer.ToString();
|
private async ValueTask WriteReactionsAsync(IReadOnlyList<Reaction> reactions)
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatReactions(IReadOnlyList<Reaction> reactions)
|
|
||||||
{
|
{
|
||||||
if (!reactions.Any())
|
if (!reactions.Any())
|
||||||
return "";
|
return;
|
||||||
|
|
||||||
var buffer = new StringBuilder();
|
await _writer.WriteLineAsync("{Reactions}");
|
||||||
|
|
||||||
buffer.AppendLine("{Reactions}");
|
|
||||||
|
|
||||||
foreach (var reaction in reactions)
|
foreach (var reaction in reactions)
|
||||||
{
|
{
|
||||||
buffer.Append(reaction.Emoji.Name);
|
await _writer.WriteAsync(reaction.Emoji.Name);
|
||||||
|
|
||||||
if (reaction.Count > 1)
|
if (reaction.Count > 1)
|
||||||
buffer.Append($" ({reaction.Count})");
|
await _writer.WriteAsync($" ({reaction.Count})");
|
||||||
|
|
||||||
buffer.Append(" ");
|
await _writer.WriteAsync(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.AppendLine();
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string FormatMessage(Message message)
|
public override async ValueTask WritePreambleAsync()
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder();
|
await _writer.WriteLineAsync('='.Repeat(62));
|
||||||
|
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
|
||||||
|
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}");
|
||||||
|
|
||||||
buffer
|
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
||||||
.AppendLine(FormatMessageHeader(message))
|
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatMarkdown(message.Content))
|
|
||||||
.AppendLine()
|
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatAttachments(message.Attachments))
|
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatEmbeds(message.Embeds))
|
|
||||||
.AppendLineIfNotNullOrWhiteSpace(FormatReactions(message.Reactions));
|
|
||||||
|
|
||||||
return buffer.Trim().ToString();
|
if (Context.Request.After != null)
|
||||||
|
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value)}");
|
||||||
|
|
||||||
|
if (Context.Request.Before != null)
|
||||||
|
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value)}");
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync('='.Repeat(62));
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task WritePreambleAsync()
|
public override async ValueTask WriteMessageAsync(Message message)
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder();
|
await WriteMessageHeaderAsync(message);
|
||||||
|
|
||||||
buffer.Append('=', 62).AppendLine();
|
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||||
buffer.AppendLine($"Guild: {Context.Guild.Name}");
|
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
|
||||||
buffer.AppendLine($"Channel: {Context.Channel.Category} / {Context.Channel.Name}");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(Context.Channel.Topic))
|
await _writer.WriteLineAsync();
|
||||||
buffer.AppendLine($"Topic: {Context.Channel.Topic}");
|
|
||||||
|
|
||||||
if (Context.After != null)
|
await WriteAttachmentsAsync(message.Attachments);
|
||||||
buffer.AppendLine($"After: {Context.After.Value.ToLocalString(Context.DateFormat)}");
|
await WriteEmbedsAsync(message.Embeds);
|
||||||
|
await WriteReactionsAsync(message.Reactions);
|
||||||
|
|
||||||
if (Context.Before != null)
|
|
||||||
buffer.AppendLine($"Before: {Context.Before.Value.ToLocalString(Context.DateFormat)}");
|
|
||||||
|
|
||||||
buffer.Append('=', 62).AppendLine();
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync(buffer.ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task WriteMessageAsync(Message message)
|
|
||||||
{
|
|
||||||
await _writer.WriteLineAsync(FormatMessage(message));
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
_messageCount++;
|
_messageCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task WritePostambleAsync()
|
public override async ValueTask WritePostambleAsync()
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder();
|
await _writer.WriteLineAsync('='.Repeat(62));
|
||||||
|
await _writer.WriteLineAsync($"Exported {_messageCount:N0} message(s)");
|
||||||
buffer
|
await _writer.WriteLineAsync('='.Repeat(62));
|
||||||
.Append('=', 62).AppendLine()
|
|
||||||
.AppendLine($"Exported {_messageCount:N0} message(s)")
|
|
||||||
.Append('=', 62).AppendLine()
|
|
||||||
.AppendLine();
|
|
||||||
|
|
||||||
await _writer.WriteLineAsync(buffer.ToString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
{
|
{
|
||||||
internal static class ColorExtensions
|
internal static class ColorExtensions
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
{
|
{
|
||||||
internal static class DateExtensions
|
internal static class DateExtensions
|
||||||
{
|
{
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
{
|
{
|
||||||
internal static class GenericExtensions
|
internal static class GenericExtensions
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
|
{
|
||||||
|
internal static class HttpClientExtensions
|
||||||
|
{
|
||||||
|
public static async ValueTask DownloadAsync(this HttpClient httpClient, string uri, string outputFilePath)
|
||||||
|
{
|
||||||
|
await using var input = await httpClient.GetStreamAsync(uri);
|
||||||
|
var output = File.Create(outputFilePath);
|
||||||
|
|
||||||
|
await input.CopyToAsync(output);
|
||||||
|
await output.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async ValueTask<JsonElement> ReadAsJsonAsync(this HttpContent content)
|
||||||
|
{
|
||||||
|
await using var stream = await content.ReadAsStreamAsync();
|
||||||
|
using var doc = await JsonDocument.ParseAsync(stream);
|
||||||
|
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
{
|
{
|
||||||
internal static class JsonElementExtensions
|
internal static class JsonElementExtensions
|
||||||
{
|
{
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
|
{
|
||||||
|
internal static class StringExtensions
|
||||||
|
{
|
||||||
|
public static string Truncate(this string str, int charCount) =>
|
||||||
|
str.Length > charCount
|
||||||
|
? str.Substring(0, charCount)
|
||||||
|
: str;
|
||||||
|
|
||||||
|
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||||
|
builder.Length > 0
|
||||||
|
? builder.Append(value)
|
||||||
|
: builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
{
|
{
|
||||||
internal static class Utf8JsonWriterExtensions
|
internal static class Utf8JsonWriterExtensions
|
||||||
{
|
{
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
using System.Net.Http;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
|
||||||
{
|
|
||||||
internal static class HttpClientExtensions
|
|
||||||
{
|
|
||||||
public static async Task<JsonElement> ReadAsJsonAsync(this HttpContent content)
|
|
||||||
{
|
|
||||||
await using var stream = await content.ReadAsStreamAsync();
|
|
||||||
using var doc = await JsonDocument.ParseAsync(stream);
|
|
||||||
|
|
||||||
return doc.RootElement.Clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Internal
|
||||||
|
{
|
||||||
|
internal static class PathEx
|
||||||
|
{
|
||||||
|
public static StringBuilder EscapePath(StringBuilder pathBuffer)
|
||||||
|
{
|
||||||
|
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||||
|
pathBuffer.Replace(invalidChar, '_');
|
||||||
|
|
||||||
|
return pathBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
||||||
|
|
||||||
|
public static string MakeUniqueFilePath(string baseFilePath, int maxAttempts = int.MaxValue)
|
||||||
|
{
|
||||||
|
if (!File.Exists(baseFilePath))
|
||||||
|
return baseFilePath;
|
||||||
|
|
||||||
|
var baseDirPath = Path.GetDirectoryName(baseFilePath);
|
||||||
|
var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||||
|
var baseFileExtension = Path.GetExtension(baseFilePath);
|
||||||
|
|
||||||
|
for (var i = 1; i <= maxAttempts; i++)
|
||||||
|
{
|
||||||
|
var filePath = $"{baseFileNameWithoutExtension} ({i}){baseFileExtension}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(baseDirPath))
|
||||||
|
filePath = Path.Combine(baseDirPath, filePath);
|
||||||
|
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseFilePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Internal
|
||||||
|
{
|
||||||
|
internal static class Singleton
|
||||||
|
{
|
||||||
|
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
|
||||||
|
{
|
||||||
|
var handler = new HttpClientHandler();
|
||||||
|
|
||||||
|
if (handler.SupportsAutomaticDecompression)
|
||||||
|
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||||
|
|
||||||
|
handler.UseCookies = false;
|
||||||
|
|
||||||
|
return new HttpClient(handler, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
public static HttpClient HttpClient { get; } = LazyHttpClient.Value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
|
||||||
{
|
|
||||||
internal static class StringExtensions
|
|
||||||
{
|
|
||||||
public static StringBuilder AppendLineIfNotNullOrWhiteSpace(this StringBuilder builder, string? value) =>
|
|
||||||
!string.IsNullOrWhiteSpace(value) ? builder.AppendLine(value) : builder;
|
|
||||||
|
|
||||||
public static StringBuilder Trim(this StringBuilder builder)
|
|
||||||
{
|
|
||||||
while (builder.Length > 0 && char.IsWhiteSpace(builder[0]))
|
|
||||||
builder.Remove(0, 1);
|
|
||||||
|
|
||||||
while (builder.Length > 0 && char.IsWhiteSpace(builder[^1]))
|
|
||||||
builder.Remove(builder.Length - 1, 1);
|
|
||||||
|
|
||||||
return builder;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-7
@@ -4,7 +4,7 @@ using System.Linq;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord
|
namespace DiscordChatExporter.Domain.Internal
|
||||||
{
|
{
|
||||||
internal class UrlBuilder
|
internal class UrlBuilder
|
||||||
{
|
{
|
||||||
@@ -19,8 +19,11 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public UrlBuilder SetQueryParameter(string key, string? value)
|
public UrlBuilder SetQueryParameter(string key, string? value, bool ignoreUnsetValue = true)
|
||||||
{
|
{
|
||||||
|
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||||
|
return this;
|
||||||
|
|
||||||
var keyEncoded = WebUtility.UrlEncode(key);
|
var keyEncoded = WebUtility.UrlEncode(key);
|
||||||
var valueEncoded = WebUtility.UrlEncode(value);
|
var valueEncoded = WebUtility.UrlEncode(value);
|
||||||
_queryParameters[keyEncoded] = valueEncoded;
|
_queryParameters[keyEncoded] = valueEncoded;
|
||||||
@@ -28,11 +31,6 @@ namespace DiscordChatExporter.Domain.Discord
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public UrlBuilder SetQueryParameterIfNotNullOrWhiteSpace(string key, string? value) =>
|
|
||||||
!string.IsNullOrWhiteSpace(value)
|
|
||||||
? SetQueryParameter(key, value)
|
|
||||||
: this;
|
|
||||||
|
|
||||||
public string Build()
|
public string Build()
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
@@ -6,23 +6,23 @@ namespace DiscordChatExporter.Domain.Markdown
|
|||||||
{
|
{
|
||||||
internal abstract class MarkdownVisitor
|
internal abstract class MarkdownVisitor
|
||||||
{
|
{
|
||||||
public virtual MarkdownNode VisitText(TextNode text) => text;
|
protected virtual MarkdownNode VisitText(TextNode text) => text;
|
||||||
|
|
||||||
public virtual MarkdownNode VisitFormatted(FormattedNode formatted)
|
protected virtual MarkdownNode VisitFormatted(FormattedNode formatted)
|
||||||
{
|
{
|
||||||
Visit(formatted.Children);
|
Visit(formatted.Children);
|
||||||
return formatted;
|
return formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
public virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => inlineCodeBlock;
|
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => inlineCodeBlock;
|
||||||
|
|
||||||
public virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => multiLineCodeBlock;
|
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => multiLineCodeBlock;
|
||||||
|
|
||||||
public virtual MarkdownNode VisitLink(LinkNode link) => link;
|
protected virtual MarkdownNode VisitLink(LinkNode link) => link;
|
||||||
|
|
||||||
public virtual MarkdownNode VisitEmoji(EmojiNode emoji) => emoji;
|
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) => emoji;
|
||||||
|
|
||||||
public virtual MarkdownNode VisitMention(MentionNode mention) => mention;
|
protected virtual MarkdownNode VisitMention(MentionNode mention) => mention;
|
||||||
|
|
||||||
public MarkdownNode Visit(MarkdownNode node) => node switch
|
public MarkdownNode Visit(MarkdownNode node) => node switch
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ namespace DiscordChatExporter.Domain.Utilities
|
|||||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) =>
|
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||||
asyncEnumerable.AggregateAsync().GetAwaiter();
|
asyncEnumerable.AggregateAsync().GetAwaiter();
|
||||||
|
|
||||||
public static async Task ParallelForEachAsync<T>(this IEnumerable<T> source, Func<T, Task> handleAsync, int degreeOfParallelism)
|
public static async ValueTask ParallelForEachAsync<T>(this IEnumerable<T> source, Func<T, Task> handleAsync, int degreeOfParallelism)
|
||||||
{
|
{
|
||||||
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:s="https://github.com/canton7/Stylet">
|
xmlns:s="https://github.com/canton7/Stylet">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<s:ApplicationLoader>
|
<s:ApplicationLoader>
|
||||||
@@ -111,6 +112,10 @@
|
|||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style
|
<Style
|
||||||
x:Key="MaterialDesignFlatDarkButton"
|
x:Key="MaterialDesignFlatDarkButton"
|
||||||
BasedOn="{StaticResource MaterialDesignFlatButton}"
|
BasedOn="{StaticResource MaterialDesignFlatButton}"
|
||||||
@@ -136,6 +141,89 @@
|
|||||||
</Trigger>
|
</Trigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Expander">
|
||||||
|
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
|
||||||
|
<DockPanel Background="{TemplateBinding Background}">
|
||||||
|
<ToggleButton
|
||||||
|
Name="HeaderSite"
|
||||||
|
BorderThickness="0"
|
||||||
|
Content="{TemplateBinding Header}"
|
||||||
|
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
|
||||||
|
ContentTemplate="{TemplateBinding HeaderTemplate}"
|
||||||
|
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
|
||||||
|
Cursor="Hand"
|
||||||
|
DockPanel.Dock="Top"
|
||||||
|
Focusable="False"
|
||||||
|
Foreground="{TemplateBinding Foreground}"
|
||||||
|
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
IsTabStop="False"
|
||||||
|
Opacity=".87"
|
||||||
|
Style="{StaticResource MaterialDesignExpanderDownHeaderStyle}"
|
||||||
|
TextElement.FontSize="15" />
|
||||||
|
<Border
|
||||||
|
Name="ContentSite"
|
||||||
|
DockPanel.Dock="Bottom"
|
||||||
|
Visibility="Collapsed">
|
||||||
|
<StackPanel
|
||||||
|
Name="ContentPanel"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
|
||||||
|
<ContentPresenter
|
||||||
|
Name="PART_Content"
|
||||||
|
ContentStringFormat="{TemplateBinding ContentStringFormat}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
|
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
|
||||||
|
Focusable="False" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsExpanded" Value="true">
|
||||||
|
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="ExpandDirection" Value="Right">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderRightHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
|
||||||
|
<Trigger Property="ExpandDirection" Value="Left">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderLeftHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
|
||||||
|
<Trigger Property="ExpandDirection" Value="Up">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderUpHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
|
||||||
|
<Trigger Property="ExpandDirection" Value="Down">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderDownHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
</s:ApplicationLoader>
|
</s:ApplicationLoader>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Converters
|
||||||
|
{
|
||||||
|
[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))]
|
||||||
|
public class TimeSpanToDateTimeConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static TimeSpanToDateTimeConverter Instance { get; } = new TimeSpanToDateTimeConverter();
|
||||||
|
|
||||||
|
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is TimeSpan timeSpanValue)
|
||||||
|
return DateTime.Today.Add(timeSpanValue);
|
||||||
|
|
||||||
|
return default(DateTime?);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is DateTime dateTimeValue)
|
||||||
|
return dateTimeValue.TimeOfDay;
|
||||||
|
|
||||||
|
return default(TimeSpan?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
||||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
||||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
||||||
<PackageReference Include="Onova" Version="2.6.0" />
|
<PackageReference Include="Onova" Version="2.6.1" />
|
||||||
<PackageReference Include="Stylet" Version="1.3.1" />
|
<PackageReference Include="Stylet" Version="1.3.4" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||||
<PackageReference Include="PropertyChanged.Fody" Version="3.2.8" PrivateAssets="all" />
|
<PackageReference Include="PropertyChanged.Fody" Version="3.2.8" PrivateAssets="all" />
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ namespace DiscordChatExporter.Gui.Services
|
|||||||
|
|
||||||
public int? LastPartitionLimit { get; set; }
|
public int? LastPartitionLimit { get; set; }
|
||||||
|
|
||||||
|
public bool LastShouldDownloadMedia { get; set; }
|
||||||
|
|
||||||
public SettingsService()
|
public SettingsService()
|
||||||
{
|
{
|
||||||
Configuration.StorageSpace = StorageSpace.Instance;
|
Configuration.StorageSpace = StorageSpace.Instance;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ namespace DiscordChatExporter.Gui.Services
|
|||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Version?> CheckForUpdatesAsync()
|
public async ValueTask<Version?> CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
if (!_settingsService.IsAutoUpdateEnabled)
|
if (!_settingsService.IsAutoUpdateEnabled)
|
||||||
return null;
|
return null;
|
||||||
@@ -32,7 +32,7 @@ namespace DiscordChatExporter.Gui.Services
|
|||||||
return check.CanUpdate ? check.LastVersion : null;
|
return check.CanUpdate ? check.LastVersion : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PrepareUpdateAsync(Version version)
|
public async ValueTask PrepareUpdateAsync(Version version)
|
||||||
{
|
{
|
||||||
if (!_settingsService.IsAutoUpdateEnabled)
|
if (!_settingsService.IsAutoUpdateEnabled)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -26,12 +26,36 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
|
|
||||||
public ExportFormat SelectedFormat { get; set; }
|
public ExportFormat SelectedFormat { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset? After { get; set; }
|
// This date/time abomination is required because we use separate controls to set these
|
||||||
|
|
||||||
public DateTimeOffset? Before { get; set; }
|
public DateTimeOffset? AfterDate { get; set; }
|
||||||
|
|
||||||
|
public bool IsAfterDateSet => AfterDate != null;
|
||||||
|
|
||||||
|
public TimeSpan? AfterTime { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
|
||||||
|
|
||||||
|
public DateTimeOffset? BeforeDate { get; set; }
|
||||||
|
|
||||||
|
public bool IsBeforeDateSet => BeforeDate != null;
|
||||||
|
|
||||||
|
public TimeSpan? BeforeTime { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
|
||||||
|
|
||||||
public int? PartitionLimit { get; set; }
|
public int? PartitionLimit { get; set; }
|
||||||
|
|
||||||
|
public bool ShouldDownloadMedia { get; set; }
|
||||||
|
|
||||||
|
// Whether to show the "advanced options" by default when the dialog opens.
|
||||||
|
// This is active if any of the advanced options are set to non-default values.
|
||||||
|
public bool IsAdvancedSectionDisplayedByDefault =>
|
||||||
|
After != default ||
|
||||||
|
Before != default ||
|
||||||
|
PartitionLimit != default ||
|
||||||
|
ShouldDownloadMedia != default;
|
||||||
|
|
||||||
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
|
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
|
||||||
{
|
{
|
||||||
_dialogManager = dialogManager;
|
_dialogManager = dialogManager;
|
||||||
@@ -40,6 +64,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
// Persist preferences
|
// Persist preferences
|
||||||
SelectedFormat = _settingsService.LastExportFormat;
|
SelectedFormat = _settingsService.LastExportFormat;
|
||||||
PartitionLimit = _settingsService.LastPartitionLimit;
|
PartitionLimit = _settingsService.LastPartitionLimit;
|
||||||
|
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Confirm()
|
public void Confirm()
|
||||||
@@ -47,33 +72,23 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
// Persist preferences
|
// Persist preferences
|
||||||
_settingsService.LastExportFormat = SelectedFormat;
|
_settingsService.LastExportFormat = SelectedFormat;
|
||||||
_settingsService.LastPartitionLimit = PartitionLimit;
|
_settingsService.LastPartitionLimit = PartitionLimit;
|
||||||
|
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||||
// Clamp 'after' and 'before' values
|
|
||||||
if (After > Before)
|
|
||||||
After = Before;
|
|
||||||
if (Before < After)
|
|
||||||
Before = After;
|
|
||||||
|
|
||||||
// If single channel - prompt file path
|
// If single channel - prompt file path
|
||||||
if (IsSingleChannel)
|
if (IsSingleChannel)
|
||||||
{
|
{
|
||||||
// Get single channel
|
|
||||||
var channel = Channels.Single();
|
var channel = Channels.Single();
|
||||||
|
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before);
|
||||||
|
|
||||||
// Generate default file name
|
// Filter
|
||||||
var defaultFileName = ChannelExporter.GetDefaultExportFileName(Guild!, channel, SelectedFormat, After, Before);
|
|
||||||
|
|
||||||
// Generate filter
|
|
||||||
var ext = SelectedFormat.GetFileExtension();
|
var ext = SelectedFormat.GetFileExtension();
|
||||||
var filter = $"{ext.ToUpperInvariant()} files|*.{ext}";
|
var filter = $"{ext.ToUpperInvariant()} files|*.{ext}";
|
||||||
|
|
||||||
// Prompt user
|
|
||||||
OutputPath = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
|
OutputPath = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
|
||||||
}
|
}
|
||||||
// If multiple channels - prompt dir path
|
// If multiple channels - prompt dir path
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Prompt user
|
|
||||||
OutputPath = _dialogManager.PromptDirectoryPath();
|
OutputPath = _dialogManager.PromptDirectoryPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +96,6 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
if (string.IsNullOrWhiteSpace(OutputPath))
|
if (string.IsNullOrWhiteSpace(OutputPath))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Close dialog
|
|
||||||
Close(true);
|
Close(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
|||||||
_viewManager = viewManager;
|
_viewManager = viewManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
|
public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
|
||||||
{
|
{
|
||||||
// Get the view that renders this viewmodel
|
// Get the view that renders this viewmodel
|
||||||
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleAutoUpdateAsync()
|
private async ValueTask HandleAutoUpdateAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -134,7 +134,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tokenValue = TokenValue?.Trim('"');
|
var tokenValue = TokenValue?.Trim('"', ' ');
|
||||||
if (string.IsNullOrWhiteSpace(tokenValue))
|
if (string.IsNullOrWhiteSpace(tokenValue))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -150,11 +150,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||||
await foreach (var guild in discord.GetUserGuildsAsync())
|
await foreach (var guild in discord.GetUserGuildsAsync())
|
||||||
{
|
{
|
||||||
var channels = await discord.GetGuildChannelsAsync(guild.Id);
|
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
|
||||||
guildChannelMap[guild] = channels
|
|
||||||
.OrderBy(c => c.Category)
|
|
||||||
.ThenBy(c => c.Name)
|
|
||||||
.ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GuildChannelMap = guildChannelMap;
|
GuildChannelMap = guildChannelMap;
|
||||||
@@ -190,9 +186,19 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await exporter.ExportAsync(dialog.Guild!, channel!,
|
var request = new ExportRequest(
|
||||||
dialog.OutputPath!, dialog.SelectedFormat, _settingsService.DateFormat,
|
dialog.Guild!,
|
||||||
dialog.PartitionLimit, dialog.After, dialog.Before, operation);
|
channel!,
|
||||||
|
dialog.OutputPath!,
|
||||||
|
dialog.SelectedFormat,
|
||||||
|
dialog.After,
|
||||||
|
dialog.Before,
|
||||||
|
dialog.PartitionLimit,
|
||||||
|
dialog.ShouldDownloadMedia,
|
||||||
|
_settingsService.DateFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
await exporter.ExportChannelAsync(request, operation);
|
||||||
|
|
||||||
Interlocked.Increment(ref successfulExportCount);
|
Interlocked.Increment(ref successfulExportCount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,54 +74,122 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
|
<!-- Advanced section -->
|
||||||
|
<StackPanel Visibility="{Binding IsChecked, ElementName=AdvancedSectionToggleButton, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
<!-- Date limits -->
|
<!-- Date limits -->
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="16,8"
|
Margin="16,8,16,4"
|
||||||
materialDesign:HintAssist.Hint="From (optional)"
|
materialDesign:HintAssist.Hint="After (date)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
DisplayDateEnd="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
SelectedDate="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
ToolTip="If this is set, only messages sent after this date will be exported" />
|
ToolTip="If this is set, only messages sent after this date will be exported" />
|
||||||
<DatePicker
|
<DatePicker
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="16,8"
|
Margin="16,8,16,4"
|
||||||
materialDesign:HintAssist.Hint="To (optional)"
|
materialDesign:HintAssist.Hint="Before (date)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
DisplayDateStart="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
SelectedDate="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
ToolTip="If this is set, only messages sent before this date will be exported" />
|
ToolTip="If this is set, only messages sent before this date will be exported" />
|
||||||
|
<materialDesign:TimePicker
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,4,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="After (time)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
IsEnabled="{Binding IsAfterDateSet}"
|
||||||
|
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
|
ToolTip="If this is set, only messages sent after this time will be exported" />
|
||||||
|
<materialDesign:TimePicker
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="16,4,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Before (time)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
IsEnabled="{Binding IsBeforeDateSet}"
|
||||||
|
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
|
ToolTip="If this is set, only messages sent before this time will be exported" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Partitioning -->
|
<!-- Partitioning -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialDesign:HintAssist.Hint="Messages per partition (optional)"
|
materialDesign:HintAssist.Hint="Messages per partition"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
Text="{Binding PartitionLimit, TargetNullValue=''}"
|
Text="{Binding PartitionLimit, TargetNullValue=''}"
|
||||||
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
|
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
|
||||||
|
|
||||||
|
<!-- Download media -->
|
||||||
|
<Grid Margin="16,16" ToolTip="If this is set, the export will include additional files such as user avatars, attached files, embedded images, etc">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="Download referenced media content" />
|
||||||
|
<ToggleButton
|
||||||
|
Grid.Column="1"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
IsChecked="{Binding ShouldDownloadMedia}" />
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<ToggleButton
|
||||||
|
x:Name="AdvancedSectionToggleButton"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="8"
|
||||||
|
Cursor="Hand"
|
||||||
|
Loaded="AdvancedSectionToggleButton_OnLoaded"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatToggleButton}"
|
||||||
|
ToolTip="Show advanced options">
|
||||||
|
<ToggleButton.Content>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="Menu" />
|
||||||
|
</ToggleButton.Content>
|
||||||
|
</ToggleButton>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
Grid.Column="2"
|
||||||
Margin="8"
|
Margin="8"
|
||||||
Command="{s:Action Confirm}"
|
Command="{s:Action Confirm}"
|
||||||
Content="EXPORT"
|
Content="EXPORT"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||||
<Button
|
<Button
|
||||||
|
Grid.Column="3"
|
||||||
Margin="8"
|
Margin="8"
|
||||||
Command="{s:Action Close}"
|
Command="{s:Action Close}"
|
||||||
Content="CANCEL"
|
Content="CANCEL"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
Style="{DynamicResource MaterialDesignFlatButton}" />
|
Style="{DynamicResource MaterialDesignFlatButton}" />
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
using System.Windows;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||||
{
|
{
|
||||||
public partial class ExportSetupView
|
public partial class ExportSetupView
|
||||||
{
|
{
|
||||||
@@ -6,5 +9,11 @@
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AdvancedSectionToggleButton_OnLoaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is ExportSetupViewModel vm)
|
||||||
|
AdvancedSectionToggleButton.IsChecked = vm.IsAdvancedSectionDisplayedByDefault;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
||||||
|
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
|
||||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
|
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
|
||||||
@@ -23,6 +24,18 @@
|
|||||||
<Window.TaskbarItemInfo>
|
<Window.TaskbarItemInfo>
|
||||||
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
|
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
|
||||||
</Window.TaskbarItemInfo>
|
</Window.TaskbarItemInfo>
|
||||||
|
<Window.Resources>
|
||||||
|
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
||||||
|
<CollectionViewSource.GroupDescriptions>
|
||||||
|
<PropertyGroupDescription PropertyName="Category" />
|
||||||
|
</CollectionViewSource.GroupDescriptions>
|
||||||
|
<CollectionViewSource.SortDescriptions>
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Category" />
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
|
||||||
|
</CollectionViewSource.SortDescriptions>
|
||||||
|
</CollectionViewSource>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -148,7 +161,7 @@
|
|||||||
<Run Text="4. Select" />
|
<Run Text="4. Select" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
|
||||||
<Run Text=">" />
|
<Run Text=">" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discordapp.com" />
|
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discord.com" />
|
||||||
<Run Text="on the left" />
|
<Run Text="on the left" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="5. Press" />
|
<Run Text="5. Press" />
|
||||||
@@ -253,12 +266,37 @@
|
|||||||
<Border Grid.Column="1">
|
<Border Grid.Column="1">
|
||||||
<ListBox
|
<ListBox
|
||||||
HorizontalContentAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
ItemsSource="{Binding AvailableChannels}"
|
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
|
||||||
SelectionMode="Extended"
|
SelectionMode="Extended"
|
||||||
TextSearch.TextPath="Model.Name">
|
TextSearch.TextPath="Model.Name"
|
||||||
|
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
|
||||||
<i:Interaction.Behaviors>
|
<i:Interaction.Behaviors>
|
||||||
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
|
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
|
||||||
</i:Interaction.Behaviors>
|
</i:Interaction.Behaviors>
|
||||||
|
<ListBox.GroupStyle>
|
||||||
|
<GroupStyle>
|
||||||
|
<GroupStyle.ContainerStyle>
|
||||||
|
<Style TargetType="{x:Type GroupItem}">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
|
||||||
|
<Expander
|
||||||
|
Margin="0"
|
||||||
|
Padding="0"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="{DynamicResource DividerBrush}"
|
||||||
|
BorderThickness="0,1,0,0"
|
||||||
|
Header="{Binding Name}"
|
||||||
|
IsExpanded="False">
|
||||||
|
<ItemsPresenter />
|
||||||
|
</Expander>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</GroupStyle.ContainerStyle>
|
||||||
|
</GroupStyle>
|
||||||
|
</ListBox.GroupStyle>
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Grid Margin="-8" Background="Transparent">
|
<Grid Margin="-8" Background="Transparent">
|
||||||
@@ -278,16 +316,14 @@
|
|||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Kind="Pound" />
|
Kind="Pound" />
|
||||||
|
|
||||||
<!-- Channel category / name -->
|
<!-- Channel name -->
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="3,8,8,8"
|
Margin="3,8,8,8"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="14">
|
FontSize="14"
|
||||||
<Run Foreground="{DynamicResource SecondaryTextBrush}" Text="{Binding Category, Mode=OneWay}" />
|
Foreground="{DynamicResource PrimaryTextBrush}"
|
||||||
<Run Text="/" />
|
Text="{Binding Name, Mode=OneWay}" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding Name, Mode=OneWay}" />
|
|
||||||
</TextBlock>
|
|
||||||
|
|
||||||
<!-- Is selected checkmark -->
|
<!-- Is selected checkmark -->
|
||||||
<materialDesign:PackIcon
|
<materialDesign:PackIcon
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||||
<Version>2.20</Version>
|
<Version>2.22</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
[](https://tyrrrz.me/donate)
|
[](https://tyrrrz.me/donate)
|
||||||
|
[](https://xscode.com/Tyrrrz/DiscordChatExporter)
|
||||||
|
|
||||||
DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works with direct messages, group messages, server channels, supports Discord's dialect of markdown and all other rich media features.
|
DiscordChatExporter can be used to export message history from a [Discord](https://discord.com) channel to a file. It works with direct messages, group messages, server channels, supports Discord's dialect of markdown and all other rich media features.
|
||||||
|
|
||||||
_For guides and other info -- check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)._
|
_For guides and other info -- check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)._
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user