mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2024-09-19 12:18:48 -04:00
154 lines
5.8 KiB
C#
154 lines
5.8 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Drawing;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using DiscordChatExporter.Core.Discord;
|
|
using DiscordChatExporter.Core.Discord.Data;
|
|
using DiscordChatExporter.Core.Utils.Extensions;
|
|
|
|
namespace DiscordChatExporter.Core.Exporting;
|
|
|
|
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|
{
|
|
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
|
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
|
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
|
|
|
private readonly ExportAssetDownloader _assetDownloader =
|
|
new(request.AssetsDirPath, request.ShouldReuseAssets);
|
|
|
|
public DiscordClient Discord { get; } = discord;
|
|
|
|
public ExportRequest Request { get; } = request;
|
|
|
|
public DateTimeOffset NormalizeDate(DateTimeOffset instant) =>
|
|
Request.IsUtcNormalizationEnabled ? instant.ToUniversalTime() : instant.ToLocalTime();
|
|
|
|
public string FormatDate(DateTimeOffset instant, string format = "g") =>
|
|
NormalizeDate(instant).ToString(format, Request.CultureInfo);
|
|
|
|
public async ValueTask PopulateChannelsAndRolesAsync(
|
|
CancellationToken cancellationToken = default
|
|
)
|
|
{
|
|
await foreach (
|
|
var channel in Discord.GetGuildChannelsAsync(Request.Guild.Id, cancellationToken)
|
|
)
|
|
{
|
|
_channelsById[channel.Id] = channel;
|
|
}
|
|
|
|
await foreach (var role in Discord.GetGuildRolesAsync(Request.Guild.Id, cancellationToken))
|
|
{
|
|
_rolesById[role.Id] = role;
|
|
}
|
|
}
|
|
|
|
// Because members cannot be pulled in bulk, we need to populate them on demand
|
|
private async ValueTask PopulateMemberAsync(
|
|
Snowflake id,
|
|
User? fallbackUser,
|
|
CancellationToken cancellationToken = default
|
|
)
|
|
{
|
|
if (_membersById.ContainsKey(id))
|
|
return;
|
|
|
|
var member = await Discord.TryGetGuildMemberAsync(Request.Guild.Id, id, cancellationToken);
|
|
|
|
// User may have left the guild since they were mentioned.
|
|
// Create a dummy member object based on the user info.
|
|
if (member is null)
|
|
{
|
|
var user = fallbackUser ?? await Discord.TryGetUserAsync(id, cancellationToken);
|
|
|
|
// User may have been deleted since they were mentioned
|
|
if (user is not null)
|
|
member = Member.CreateFallback(user);
|
|
}
|
|
|
|
// Store the result even if it's null, to avoid re-fetching non-existing members
|
|
_membersById[id] = member;
|
|
}
|
|
|
|
public async ValueTask PopulateMemberAsync(
|
|
Snowflake id,
|
|
CancellationToken cancellationToken = default
|
|
) => await PopulateMemberAsync(id, null, cancellationToken);
|
|
|
|
public async ValueTask PopulateMemberAsync(
|
|
User user,
|
|
CancellationToken cancellationToken = default
|
|
) => await PopulateMemberAsync(user.Id, user, cancellationToken);
|
|
|
|
public Member? TryGetMember(Snowflake id) => _membersById.GetValueOrDefault(id);
|
|
|
|
public Channel? TryGetChannel(Snowflake id) => _channelsById.GetValueOrDefault(id);
|
|
|
|
public Role? TryGetRole(Snowflake id) => _rolesById.GetValueOrDefault(id);
|
|
|
|
public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
|
|
TryGetMember(id)
|
|
?.RoleIds.Select(TryGetRole)
|
|
.WhereNotNull()
|
|
.OrderByDescending(r => r.Position)
|
|
.ToArray() ?? [];
|
|
|
|
public Color? TryGetUserColor(Snowflake id) =>
|
|
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
|
|
|
public async ValueTask<string> ResolveAssetUrlAsync(
|
|
string url,
|
|
CancellationToken cancellationToken = default
|
|
)
|
|
{
|
|
if (!Request.ShouldDownloadAssets)
|
|
return url;
|
|
|
|
try
|
|
{
|
|
var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken);
|
|
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
|
|
|
// Prefer the relative path so that the export package can be copied around without breaking references.
|
|
// However, if the assets directory lies outside of the export directory, use the absolute path instead.
|
|
var shouldUseAbsoluteFilePath =
|
|
relativeFilePath.StartsWith(
|
|
".." + Path.DirectorySeparatorChar,
|
|
StringComparison.Ordinal
|
|
)
|
|
|| relativeFilePath.StartsWith(
|
|
".." + Path.AltDirectorySeparatorChar,
|
|
StringComparison.Ordinal
|
|
);
|
|
|
|
var optimalFilePath = shouldUseAbsoluteFilePath ? filePath : relativeFilePath;
|
|
|
|
// For HTML, the path needs to be properly formatted
|
|
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
|
{
|
|
// Format the path into a valid file URI
|
|
var href = new Uri(new Uri("file:///"), optimalFilePath).ToString();
|
|
|
|
// File schema does not support relative paths, so strip it if that's the case
|
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1155
|
|
return shouldUseAbsoluteFilePath ? href : href[8..];
|
|
}
|
|
|
|
return optimalFilePath;
|
|
}
|
|
// Try to catch only exceptions related to failed HTTP requests
|
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
|
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
|
{
|
|
// We don't want this to crash the exporting process in case of failure.
|
|
// TODO: add logging so we can be more liberal with catching exceptions.
|
|
return url;
|
|
}
|
|
}
|
|
}
|