mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 07:04:40 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 088612a78f | |||
| de531d4a97 | |||
| 5fc2fae28a | |||
| ddfbe51cfa | |||
| bcf652edbe | |||
| 1f5407f032 | |||
| 83a63c2ed7 | |||
| f33a0ac0a7 | |||
| 779b8984d7 | |||
| 3e44a2e88d | |||
| b44e6087be | |||
| 49b96087d3 | |||
| e175c93038 | |||
| 0ee1107638 | |||
| 81a6d363d1 | |||
| 90d71c5b9e | |||
| 5e9c7392db | |||
| c4137cf77e | |||
| 51192425b7 | |||
| 8776a6955b | |||
| c69211797f | |||
| 768fb88c8c | |||
| d4f62387a5 | |||
| bd4cfcdaf6 |
@@ -151,5 +151,6 @@ jobs:
|
|||||||
Content-Type: application/json; charset=UTF-8
|
Content-Type: application/json; charset=UTF-8
|
||||||
body: |
|
body: |
|
||||||
{
|
{
|
||||||
|
"avatar_url": "https://raw.githubusercontent.com/${{ github.event.repository.full_name }}/${{ github.ref_name }}/favicon.png",
|
||||||
"content": "**${{ github.event.repository.name }}** new version released!\nVersion: `${{ github.ref_name }}`\nChangelog: <${{ github.event.repository.html_url }}/blob/${{ github.ref_name }}/Changelog.md>"
|
"content": "**${{ github.event.repository.name }}** new version released!\nVersion: `${{ github.ref_name }}`\nChangelog: <${{ github.event.repository.html_url }}/blob/${{ github.ref_name }}/Changelog.md>"
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v2.40.3 (29-Jul-2023)
|
||||||
|
|
||||||
|
- GUI changes:
|
||||||
|
- Fixed an issue where guild categories were rendered with blank names.
|
||||||
|
|
||||||
|
## v2.40.2 (27-Jul-2023)
|
||||||
|
|
||||||
|
- General changes:
|
||||||
|
- Improved error reporting on unexpected HTTP errors.
|
||||||
|
- CLI changes:
|
||||||
|
- Fixed an issue which caused a crash when trying to export a private (but accessible) channel inside a private (but not accessible) category.
|
||||||
|
|
||||||
|
## v2.40.1 (21-Jul-2023)
|
||||||
|
|
||||||
|
- General changes:
|
||||||
|
- Fixed an issue where the fallback avatar (i.e. the avatar used for when the user doesn't have a custom one set) was not resolved properly for users that don't have a discriminator.
|
||||||
|
- CLI changes:
|
||||||
|
- Added the `--include-vc` option to `exportall` and `exportguild` commands, which instructs whether to include voice-channel-accompanying text channels in the export. By default, this is set to `true` to match the previous behavior. To disable the option, use `--include-vc false`.
|
||||||
|
- GUI changes:
|
||||||
|
- Replaced the blur effect applied to the token text box with a password mask.
|
||||||
|
- JSON format changes:
|
||||||
|
- Added the `exportedAt` field to the export metadata, which contains the timestamp corresponding to when the export was created. (Thanks [@slatinsky](https://github.com/slatinsky))
|
||||||
|
- Added the `roles` field to the object describing a user. This field contains an array of objects that describe the roles assigned to the user. (Thanks [@slatinsky](https://github.com/slatinsky))
|
||||||
|
|
||||||
## v2.40 (08-Jun-2023)
|
## v2.40 (08-Jun-2023)
|
||||||
|
|
||||||
- General changes:
|
- General changes:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
<Version>2.40</Version>
|
<Version>2.40.3</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
|
|||||||
@@ -12,17 +12,17 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.0.3" />
|
<PackageReference Include="AngleSharp" Version="1.0.4" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.11.0" />
|
<PackageReference Include="FluentAssertions" Version="6.11.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.2" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" />
|
||||||
<PackageReference Include="ReflectionMagic" Version="4.1.0" />
|
<PackageReference Include="ReflectionMagic" Version="5.0.0" />
|
||||||
<PackageReference Include="xunit" Version="2.4.2" />
|
<PackageReference Include="xunit" Version="2.5.0" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -48,23 +48,22 @@ public static class ExportWrapper
|
|||||||
var fileName = channelId.ToString() + '.' + format.GetFileExtension();
|
var fileName = channelId.ToString() + '.' + format.GetFileExtension();
|
||||||
var filePath = Path.Combine(DirPath, fileName);
|
var filePath = Path.Combine(DirPath, fileName);
|
||||||
|
|
||||||
// Lock separately for each channel and format
|
using var _ = await Locker.LockAsync(filePath);
|
||||||
using (await Locker.LockAsync(filePath))
|
using var console = new FakeConsole();
|
||||||
{
|
|
||||||
// Perform the export only if it hasn't been done before
|
|
||||||
if (!File.Exists(filePath))
|
|
||||||
{
|
|
||||||
await new ExportChannelsCommand
|
|
||||||
{
|
|
||||||
Token = Secrets.DiscordToken,
|
|
||||||
ChannelIds = new[] { channelId },
|
|
||||||
ExportFormat = format,
|
|
||||||
OutputPath = filePath
|
|
||||||
}.ExecuteAsync(new FakeConsole());
|
|
||||||
}
|
|
||||||
|
|
||||||
return await File.ReadAllTextAsync(filePath);
|
// Perform the export only if it hasn't been done before
|
||||||
|
if (!File.Exists(filePath))
|
||||||
|
{
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = new[] { channelId },
|
||||||
|
ExportFormat = format,
|
||||||
|
OutputPath = filePath
|
||||||
|
}.ExecuteAsync(console);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await File.ReadAllTextAsync(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse(
|
public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse(
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await progressContext.StartTaskAsync(
|
await progressContext.StartTaskAsync(
|
||||||
$"{channel.Category.Name} / {channel.Name}",
|
$"{channel.Category} / {channel.Name}",
|
||||||
async progress =>
|
async progress =>
|
||||||
{
|
{
|
||||||
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
||||||
@@ -253,7 +253,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
foreach (var (channel, error) in errors)
|
foreach (var (channel, error) in errors)
|
||||||
{
|
{
|
||||||
await console.Error.WriteAsync($"{channel.Category.Name} / {channel.Name}: ");
|
await console.Error.WriteAsync($"{channel.Category} / {channel.Name}: ");
|
||||||
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
await console.Error.WriteLineAsync(error);
|
await console.Error.WriteLineAsync(error);
|
||||||
@@ -290,7 +290,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
foreach (var guildChannel in guildChannels)
|
foreach (var guildChannel in guildChannels)
|
||||||
{
|
{
|
||||||
if (guildChannel.Category.Id == channel.Id)
|
if (guildChannel.Parent?.Id == channel.Id)
|
||||||
channels.Add(guildChannel);
|
channels.Add(guildChannel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System.Globalization;
|
||||||
using System.Globalization;
|
|
||||||
using CliFx.Extensibility;
|
using CliFx.Extensibility;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands.Converters;
|
namespace DiscordChatExporter.Cli.Commands.Converters;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
)]
|
)]
|
||||||
public bool IncludeGuildChannels { get; init; } = true;
|
public bool IncludeGuildChannels { get; init; } = true;
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"include-vc",
|
||||||
|
Description = "Include voice channels."
|
||||||
|
)]
|
||||||
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"data-package",
|
"data-package",
|
||||||
Description =
|
Description =
|
||||||
@@ -97,6 +103,8 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
channels.RemoveAll(c => c.Kind.IsDirect());
|
channels.RemoveAll(c => c.Kind.IsDirect());
|
||||||
if (!IncludeGuildChannels)
|
if (!IncludeGuildChannels)
|
||||||
channels.RemoveAll(c => c.Kind.IsGuild());
|
channels.RemoveAll(c => c.Kind.IsGuild());
|
||||||
|
if (!IncludeVoiceChannels)
|
||||||
|
channels.RemoveAll(c => c.Kind.IsVoice());
|
||||||
|
|
||||||
await base.ExecuteAsync(console, channels);
|
await base.ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
)]
|
)]
|
||||||
public required Snowflake GuildId { get; init; }
|
public required Snowflake GuildId { get; init; }
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"include-vc",
|
||||||
|
Description = "Include voice channels."
|
||||||
|
)]
|
||||||
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
await base.ExecuteAsync(console);
|
await base.ExecuteAsync(console);
|
||||||
@@ -29,6 +35,7 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
|
|
||||||
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||||
|
.Where(c => IncludeVoiceChannels || !c.Kind.IsVoice())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
await base.ExecuteAsync(console, channels);
|
await base.ExecuteAsync(console, channels);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||||||
|
|
||||||
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||||
.OrderBy(c => c.Category.Position)
|
.OrderBy(c => c.Parent?.Position)
|
||||||
.ThenBy(c => c.Name)
|
.ThenBy(c => c.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||||||
|
|
||||||
var threads = IncludeThreads
|
var threads = IncludeThreads
|
||||||
? (await Discord.GetGuildThreadsAsync(GuildId, cancellationToken)).OrderBy(c => c.Name).ToArray()
|
? (await Discord.GetGuildThreadsAsync(GuildId, cancellationToken)).OrderBy(c => c.Name).ToArray()
|
||||||
: Array.Empty<ChannelThread>();
|
: Array.Empty<Channel>();
|
||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
@@ -58,22 +58,22 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||||||
|
|
||||||
// Channel category / name
|
// Channel category / name
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}");
|
await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}");
|
||||||
|
|
||||||
var channelThreads = threads.Where(t => t.ParentId == channel.Id).ToArray();
|
var channelThreads = threads.Where(t => t.Parent?.Id == channel.Id).ToArray();
|
||||||
var channelThreadIdMaxLength = channelThreads
|
var channelThreadIdMaxLength = channelThreads
|
||||||
.Select(t => t.Id.ToString().Length)
|
.Select(t => t.Id.ToString().Length)
|
||||||
.OrderDescending()
|
.OrderDescending()
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
foreach (var thread in channelThreads)
|
foreach (var channelThread in channelThreads)
|
||||||
{
|
{
|
||||||
// Indent
|
// Indent
|
||||||
await console.Output.WriteAsync(" * ");
|
await console.Output.WriteAsync(" * ");
|
||||||
|
|
||||||
// Thread ID
|
// Thread ID
|
||||||
await console.Output.WriteAsync(
|
await console.Output.WriteAsync(
|
||||||
thread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
|
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
|
||||||
);
|
);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
@@ -82,7 +82,7 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||||||
|
|
||||||
// Thread name
|
// Thread name
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
await console.Output.WriteAsync($"Thread / {thread.Name}");
|
await console.Output.WriteAsync($"Thread / {channelThread.Name}");
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
@@ -90,7 +90,7 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||||||
|
|
||||||
// Thread status
|
// Thread status
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
await console.Output.WriteLineAsync(thread.IsActive ? "Active" : "Archived");
|
await console.Output.WriteLineAsync(channelThread.IsActive ? "Active" : "Archived");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public class GetDirectChannelsCommand : DiscordCommandBase
|
|||||||
|
|
||||||
// Channel category / name
|
// Channel category / name
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}");
|
await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,19 +11,38 @@ public partial record Channel(
|
|||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
ChannelKind Kind,
|
ChannelKind Kind,
|
||||||
Snowflake GuildId,
|
Snowflake GuildId,
|
||||||
ChannelCategory Category,
|
Channel? Parent,
|
||||||
string Name,
|
string Name,
|
||||||
int? Position,
|
int? Position,
|
||||||
string? IconUrl,
|
string? IconUrl,
|
||||||
string? Topic,
|
string? Topic,
|
||||||
|
bool IsActive,
|
||||||
Snowflake? LastMessageId) : IHasId
|
Snowflake? LastMessageId) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
// Used for visual backwards-compatibility with old exports, where
|
||||||
|
// channels without a parent (i.e. mostly DM channels) or channels
|
||||||
|
// with an inaccessible parent (i.e. inside private categories) had
|
||||||
|
// a fallback category created for them.
|
||||||
|
public string Category => Parent?.Name ?? Kind switch
|
||||||
|
{
|
||||||
|
ChannelKind.GuildCategory => "Category",
|
||||||
|
ChannelKind.GuildTextChat => "Text",
|
||||||
|
ChannelKind.DirectTextChat => "Private",
|
||||||
|
ChannelKind.DirectGroupTextChat => "Group",
|
||||||
|
ChannelKind.GuildPrivateThread => "Private Thread",
|
||||||
|
ChannelKind.GuildPublicThread => "Public Thread",
|
||||||
|
ChannelKind.GuildNews => "News",
|
||||||
|
ChannelKind.GuildNewsThread => "News Thread",
|
||||||
|
_ => "Default"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only needed for WPF data binding. Don't use anywhere else.
|
||||||
|
public bool IsVoice => Kind.IsVoice();
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Channel
|
public partial record Channel
|
||||||
{
|
{
|
||||||
public static Channel Parse(JsonElement json, ChannelCategory? categoryHint = null, int? positionHint = null)
|
public static Channel Parse(JsonElement json, Channel? parent = null, int? positionHint = null)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
||||||
@@ -32,8 +51,6 @@ public partial record Channel
|
|||||||
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ??
|
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ??
|
||||||
Guild.DirectMessages.Id;
|
Guild.DirectMessages.Id;
|
||||||
|
|
||||||
var category = categoryHint ?? ChannelCategory.CreateDefault(kind);
|
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
// Guild channel
|
// Guild channel
|
||||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
||||||
@@ -60,6 +77,11 @@ public partial record Channel
|
|||||||
|
|
||||||
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
||||||
|
|
||||||
|
var isActive = !json
|
||||||
|
.GetPropertyOrNull("thread_metadata")?
|
||||||
|
.GetPropertyOrNull("archived")?
|
||||||
|
.GetBooleanOrNull() ?? true;
|
||||||
|
|
||||||
var lastMessageId = json
|
var lastMessageId = json
|
||||||
.GetPropertyOrNull("last_message_id")?
|
.GetPropertyOrNull("last_message_id")?
|
||||||
.GetNonWhiteSpaceStringOrNull()?
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
@@ -69,11 +91,12 @@ public partial record Channel
|
|||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
guildId,
|
guildId,
|
||||||
category,
|
parent,
|
||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
topic,
|
topic,
|
||||||
|
isActive,
|
||||||
lastMessageId
|
lastMessageId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
using JsonExtensions.Reading;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data;
|
|
||||||
|
|
||||||
public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
|
|
||||||
{
|
|
||||||
public static ChannelCategory CreateDefault(ChannelKind channelKind) => new(
|
|
||||||
Snowflake.Zero,
|
|
||||||
channelKind switch
|
|
||||||
{
|
|
||||||
ChannelKind.GuildTextChat => "Text",
|
|
||||||
ChannelKind.DirectTextChat => "Private",
|
|
||||||
ChannelKind.DirectGroupTextChat => "Group",
|
|
||||||
ChannelKind.GuildNews => "News",
|
|
||||||
_ => "Default"
|
|
||||||
},
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
|
|
||||||
{
|
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
|
||||||
|
|
||||||
var name =
|
|
||||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
|
||||||
id.ToString();
|
|
||||||
|
|
||||||
var position =
|
|
||||||
positionHint ??
|
|
||||||
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
|
||||||
|
|
||||||
return new ChannelCategory(id, name, position);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,4 +24,7 @@ public static class ChannelKindExtensions
|
|||||||
|
|
||||||
public static bool IsGuild(this ChannelKind kind) =>
|
public static bool IsGuild(this ChannelKind kind) =>
|
||||||
!kind.IsDirect();
|
!kind.IsDirect();
|
||||||
|
|
||||||
|
public static bool IsVoice(this ChannelKind kind) =>
|
||||||
|
kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
||||||
}
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
using JsonExtensions.Reading;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data;
|
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#channel-object-example-thread-channel
|
|
||||||
public record ChannelThread(
|
|
||||||
Snowflake Id,
|
|
||||||
ChannelKind Kind,
|
|
||||||
Snowflake GuildId,
|
|
||||||
Snowflake ParentId,
|
|
||||||
string Name,
|
|
||||||
bool IsActive,
|
|
||||||
Snowflake? LastMessageId) : IHasId
|
|
||||||
{
|
|
||||||
public static ChannelThread Parse(JsonElement json)
|
|
||||||
{
|
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
|
||||||
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
|
||||||
var guildId = json.GetProperty("guild_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
|
||||||
var parentId = json.GetProperty("parent_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
|
||||||
|
|
||||||
var isActive = !json
|
|
||||||
.GetPropertyOrNull("thread_metadata")?
|
|
||||||
.GetPropertyOrNull("archived")?
|
|
||||||
.GetBooleanOrNull() ?? true;
|
|
||||||
|
|
||||||
var lastMessageId = json
|
|
||||||
.GetPropertyOrNull("last_message_id")?
|
|
||||||
.GetNonWhiteSpaceStringOrNull()?
|
|
||||||
.Pipe(Snowflake.Parse);
|
|
||||||
|
|
||||||
return new ChannelThread(
|
|
||||||
id,
|
|
||||||
kind,
|
|
||||||
guildId,
|
|
||||||
parentId,
|
|
||||||
name,
|
|
||||||
isActive,
|
|
||||||
lastMessageId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -47,8 +47,8 @@ public static class ImageCdn
|
|||||||
? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}"
|
? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}"
|
||||||
: $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}";
|
: $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}";
|
||||||
|
|
||||||
public static string GetFallbackUserAvatarUrl(int discriminator) =>
|
public static string GetFallbackUserAvatarUrl(int index = 0) =>
|
||||||
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
$"https://cdn.discordapp.com/embed/avatars/{index}.png";
|
||||||
|
|
||||||
public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) =>
|
public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) =>
|
||||||
avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
|||||||
public static Guild DirectMessages { get; } = new(
|
public static Guild DirectMessages { get; } = new(
|
||||||
Snowflake.Zero,
|
Snowflake.Zero,
|
||||||
"Direct Messages",
|
"Direct Messages",
|
||||||
ImageCdn.GetFallbackUserAvatarUrl(0)
|
ImageCdn.GetFallbackUserAvatarUrl()
|
||||||
);
|
);
|
||||||
|
|
||||||
public static Guild Parse(JsonElement json)
|
public static Guild Parse(JsonElement json)
|
||||||
@@ -25,7 +25,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
|||||||
.GetPropertyOrNull("icon")?
|
.GetPropertyOrNull("icon")?
|
||||||
.GetNonWhiteSpaceStringOrNull()?
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
|
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
|
||||||
ImageCdn.GetFallbackUserAvatarUrl(0);
|
ImageCdn.GetFallbackUserAvatarUrl();
|
||||||
|
|
||||||
return new Guild(id, name, iconUrl);
|
return new Guild(id, name, iconUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public partial record Member(
|
|||||||
|
|
||||||
public partial record Member
|
public partial record Member
|
||||||
{
|
{
|
||||||
public static Member CreateDefault(User user) => new(user, null, null, Array.Empty<Snowflake>());
|
public static Member CreateFallback(User user) =>
|
||||||
|
new(user, null, null, Array.Empty<Snowflake>());
|
||||||
|
|
||||||
public static Member Parse(JsonElement json, Snowflake? guildId = null)
|
public static Member Parse(JsonElement json, Snowflake? guildId = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,12 +45,14 @@ public partial record User
|
|||||||
var name = json.GetProperty("username").GetNonNullString();
|
var name = json.GetProperty("username").GetNonNullString();
|
||||||
var displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name;
|
var displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name;
|
||||||
|
|
||||||
|
var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6);
|
||||||
|
|
||||||
var avatarUrl =
|
var avatarUrl =
|
||||||
json
|
json
|
||||||
.GetPropertyOrNull("avatar")?
|
.GetPropertyOrNull("avatar")?
|
||||||
.GetNonWhiteSpaceStringOrNull()?
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
|
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
|
||||||
ImageCdn.GetFallbackUserAvatarUrl(discriminator ?? 0);
|
ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
|
||||||
|
|
||||||
return new User(id, isBot, discriminator, name, displayName, avatarUrl);
|
return new User(id, isBot, discriminator, name, displayName, avatarUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ public class DiscordClient
|
|||||||
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
|
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||||
return TokenKind.Bot;
|
return TokenKind.Bot;
|
||||||
|
|
||||||
throw DiscordChatExporterException.Unauthorized();
|
throw new DiscordChatExporterException("Authentication token is invalid.", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||||
@@ -129,10 +129,26 @@ public class DiscordClient
|
|||||||
{
|
{
|
||||||
throw response.StatusCode switch
|
throw response.StatusCode switch
|
||||||
{
|
{
|
||||||
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(),
|
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
|
||||||
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(),
|
"Authentication token is invalid.",
|
||||||
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(url),
|
true
|
||||||
_ => DiscordChatExporterException.FailedHttpRequest(response)
|
),
|
||||||
|
|
||||||
|
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
|
||||||
|
$"Request to '{url}' failed: forbidden."
|
||||||
|
),
|
||||||
|
|
||||||
|
HttpStatusCode.NotFound => throw new DiscordChatExporterException(
|
||||||
|
$"Request to '{url}' failed: not found."
|
||||||
|
),
|
||||||
|
|
||||||
|
_ => throw new DiscordChatExporterException(
|
||||||
|
$"""
|
||||||
|
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
|
||||||
|
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
|
||||||
|
""",
|
||||||
|
true
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,10 +235,10 @@ public class DiscordClient
|
|||||||
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
|
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var categories = channelsJson
|
var parentsById = channelsJson
|
||||||
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory)
|
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory)
|
||||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
.Select((j, i) => Channel.Parse(j, null, i + 1))
|
||||||
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
.ToDictionary(j => j.Id);
|
||||||
|
|
||||||
// Discord channel positions are relative, so we need to normalize them
|
// Discord channel positions are relative, so we need to normalize them
|
||||||
// so that the user may refer to them more easily in file name templates.
|
// so that the user may refer to them more easily in file name templates.
|
||||||
@@ -230,21 +246,19 @@ public class DiscordClient
|
|||||||
|
|
||||||
foreach (var channelJson in channelsJson)
|
foreach (var channelJson in channelsJson)
|
||||||
{
|
{
|
||||||
var parentId = channelJson
|
var parent = channelJson
|
||||||
.GetPropertyOrNull("parent_id")?
|
.GetPropertyOrNull("parent_id")?
|
||||||
.GetNonWhiteSpaceStringOrNull();
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse)
|
||||||
|
.Pipe(parentsById.GetValueOrDefault);
|
||||||
|
|
||||||
var category = !string.IsNullOrWhiteSpace(parentId)
|
yield return Channel.Parse(channelJson, parent, position);
|
||||||
? categories.GetValueOrDefault(parentId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
yield return Channel.Parse(channelJson, category, position);
|
|
||||||
position++;
|
position++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<ChannelThread> GetGuildThreadsAsync(
|
public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
|
||||||
Snowflake guildId,
|
Snowflake guildId,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -264,13 +278,14 @@ public class DiscordClient
|
|||||||
.SetQueryParameter("offset", currentOffset.ToString())
|
.SetQueryParameter("offset", currentOffset.ToString())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on channels that the user cannot access
|
||||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
if (response is null)
|
if (response is null)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray())
|
foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray())
|
||||||
{
|
{
|
||||||
yield return ChannelThread.Parse(threadJson);
|
yield return Channel.Parse(threadJson, channel);
|
||||||
currentOffset++;
|
currentOffset++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +299,19 @@ public class DiscordClient
|
|||||||
{
|
{
|
||||||
// Active threads
|
// Active threads
|
||||||
{
|
{
|
||||||
|
var parentsById = channels.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/threads/active", cancellationToken);
|
var response = await GetJsonResponseAsync($"guilds/{guildId}/threads/active", cancellationToken);
|
||||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||||
yield return ChannelThread.Parse(threadJson);
|
{
|
||||||
|
var parent = threadJson
|
||||||
|
.GetPropertyOrNull("parent_id")?
|
||||||
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse)
|
||||||
|
.Pipe(parentsById.GetValueOrDefault);
|
||||||
|
|
||||||
|
yield return Channel.Parse(threadJson, parent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var channel in channels)
|
foreach (var channel in channels)
|
||||||
@@ -299,7 +324,7 @@ public class DiscordClient
|
|||||||
);
|
);
|
||||||
|
|
||||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||||
yield return ChannelThread.Parse(threadJson);
|
yield return Channel.Parse(threadJson, channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private archived threads
|
// Private archived threads
|
||||||
@@ -310,7 +335,7 @@ public class DiscordClient
|
|||||||
);
|
);
|
||||||
|
|
||||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||||
yield return ChannelThread.Parse(threadJson);
|
yield return Channel.Parse(threadJson, channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +365,7 @@ public class DiscordClient
|
|||||||
return response?.Pipe(j => Member.Parse(j, guildId));
|
return response?.Pipe(j => Member.Parse(j, guildId));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<Invite?> TryGetGuildInviteAsync(
|
public async ValueTask<Invite?> TryGetInviteAsync(
|
||||||
string code,
|
string code,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -348,23 +373,6 @@ public class DiscordClient
|
|||||||
return response?.Pipe(Invite.Parse);
|
return response?.Pipe(Invite.Parse);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(
|
|
||||||
Snowflake channelId,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
|
||||||
return ChannelCategory.Parse(response);
|
|
||||||
}
|
|
||||||
// In some cases, Discord API returns an empty body when requesting a channel.
|
|
||||||
// Use an empty channel category as fallback for these cases.
|
|
||||||
catch (DiscordChatExporterException)
|
|
||||||
{
|
|
||||||
return new ChannelCategory(channelId, "Unknown Category", 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Channel> GetChannelAsync(
|
public async ValueTask<Channel> GetChannelAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -376,11 +384,21 @@ public class DiscordClient
|
|||||||
.GetNonWhiteSpaceStringOrNull()?
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
.Pipe(Snowflake.Parse);
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
var category = parentId is not null
|
try
|
||||||
? await GetChannelCategoryAsync(parentId.Value, cancellationToken)
|
{
|
||||||
: null;
|
var parent = parentId is not null
|
||||||
|
? await GetChannelAsync(parentId.Value, cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
return Channel.Parse(response, category);
|
return Channel.Parse(response, parent);
|
||||||
|
}
|
||||||
|
// It's possible for the parent channel to be inaccessible, despite the
|
||||||
|
// child channel being accessible.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||||
|
catch (DiscordChatExporterException)
|
||||||
|
{
|
||||||
|
return Channel.Parse(response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<PackageReference Include="AsyncKeyedLock" Version="6.2.1" />
|
<PackageReference Include="AsyncKeyedLock" Version="6.2.1" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Polly" Version="7.2.3" />
|
<PackageReference Include="Polly" Version="7.2.4" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.4.3" />
|
<PackageReference Include="RazorBlade" Version="0.4.3" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
|
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.2.15" />
|
<PackageReference Include="YoutubeExplode" Version="6.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Net.Http;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exceptions;
|
namespace DiscordChatExporter.Core.Exceptions;
|
||||||
|
|
||||||
public partial class DiscordChatExporterException : Exception
|
public class DiscordChatExporterException : Exception
|
||||||
{
|
{
|
||||||
public bool IsFatal { get; }
|
public bool IsFatal { get; }
|
||||||
|
|
||||||
@@ -12,33 +11,4 @@ public partial class DiscordChatExporterException : Exception
|
|||||||
{
|
{
|
||||||
IsFatal = isFatal;
|
IsFatal = isFatal;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public partial class DiscordChatExporterException
|
|
||||||
{
|
|
||||||
internal static DiscordChatExporterException FailedHttpRequest(HttpResponseMessage response)
|
|
||||||
{
|
|
||||||
var message = $@"
|
|
||||||
Failed to perform an HTTP request.
|
|
||||||
|
|
||||||
[Request]
|
|
||||||
{response.RequestMessage}
|
|
||||||
|
|
||||||
[Response]
|
|
||||||
{response}";
|
|
||||||
|
|
||||||
return new DiscordChatExporterException(message.Trim(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException Unauthorized() =>
|
|
||||||
new("Authentication token is invalid.", true);
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException Forbidden() =>
|
|
||||||
new("Access is forbidden.");
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException NotFound(string resourceId) =>
|
|
||||||
new($"Requested resource ({resourceId}) does not exist.");
|
|
||||||
|
|
||||||
internal static DiscordChatExporterException ChannelIsEmpty() =>
|
|
||||||
new("Channel is empty or contains no messages for the specified period.");
|
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,19 @@ public class ChannelExporter
|
|||||||
{
|
{
|
||||||
// Check if the channel is empty
|
// Check if the channel is empty
|
||||||
if (request.Channel.LastMessageId is null)
|
if (request.Channel.LastMessageId is null)
|
||||||
throw DiscordChatExporterException.ChannelIsEmpty();
|
{
|
||||||
|
throw new DiscordChatExporterException(
|
||||||
|
"Channel does not contain any messages."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the 'after' boundary is valid
|
// Check if the 'after' boundary is valid
|
||||||
if (request.After is not null && request.Channel.LastMessageId < request.After)
|
if (request.After is not null && request.Channel.LastMessageId < request.After)
|
||||||
throw DiscordChatExporterException.ChannelIsEmpty();
|
{
|
||||||
|
throw new DiscordChatExporterException(
|
||||||
|
"Channel does not contain any messages within the specified period."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Build context
|
// Build context
|
||||||
var context = new ExportContext(_discord, request);
|
var context = new ExportContext(_discord, request);
|
||||||
@@ -50,6 +58,10 @@ public class ChannelExporter
|
|||||||
|
|
||||||
// Throw if no messages were exported
|
// Throw if no messages were exported
|
||||||
if (messageExporter.MessagesExported <= 0)
|
if (messageExporter.MessagesExported <= 0)
|
||||||
throw DiscordChatExporterException.ChannelIsEmpty();
|
{
|
||||||
|
throw new DiscordChatExporterException(
|
||||||
|
"Channel does not contain any matching messages within the specified period."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,50 +38,49 @@ internal partial class ExportAssetDownloader
|
|||||||
var fileName = GetFileNameFromUrl(url);
|
var fileName = GetFileNameFromUrl(url);
|
||||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||||
|
|
||||||
using (await Locker.LockAsync(filePath, cancellationToken))
|
using var _ = await Locker.LockAsync(filePath, cancellationToken);
|
||||||
{
|
|
||||||
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
|
||||||
return cachedFilePath;
|
|
||||||
|
|
||||||
// Reuse existing files if we're allowed to
|
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||||
if (_reuse && File.Exists(filePath))
|
return cachedFilePath;
|
||||||
return _pathCache[url] = filePath;
|
|
||||||
|
|
||||||
Directory.CreateDirectory(_workingDirPath);
|
|
||||||
|
|
||||||
await Http.ResiliencePolicy.ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
// Download the file
|
|
||||||
using var response = await Http.Client.GetAsync(url, cancellationToken);
|
|
||||||
await using (var output = File.Create(filePath))
|
|
||||||
await response.Content.CopyToAsync(output, cancellationToken);
|
|
||||||
|
|
||||||
// Try to set the file date according to the last-modified header
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
|
||||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
|
|
||||||
? instant
|
|
||||||
: (DateTimeOffset?)null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastModified is not null)
|
|
||||||
{
|
|
||||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// This can apparently fail for some reason.
|
|
||||||
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Reuse existing files if we're allowed to
|
||||||
|
if (_reuse && File.Exists(filePath))
|
||||||
return _pathCache[url] = filePath;
|
return _pathCache[url] = filePath;
|
||||||
}
|
|
||||||
|
Directory.CreateDirectory(_workingDirPath);
|
||||||
|
|
||||||
|
await Http.ResiliencePolicy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// Download the file
|
||||||
|
using var response = await Http.Client.GetAsync(url, cancellationToken);
|
||||||
|
await using (var output = File.Create(filePath))
|
||||||
|
await response.Content.CopyToAsync(output, cancellationToken);
|
||||||
|
|
||||||
|
// Try to set the file date according to the last-modified header
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||||
|
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
|
||||||
|
? instant
|
||||||
|
: (DateTimeOffset?)null
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastModified is not null)
|
||||||
|
{
|
||||||
|
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// This can apparently fail for some reason.
|
||||||
|
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return _pathCache[url] = filePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ internal class ExportContext
|
|||||||
|
|
||||||
// User may have been deleted since they were mentioned
|
// User may have been deleted since they were mentioned
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
member = Member.CreateDefault(user);
|
member = Member.CreateFallback(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the result even if it's null, to avoid re-fetching non-existing members
|
// Store the result even if it's null, to avoid re-fetching non-existing members
|
||||||
@@ -89,22 +89,17 @@ internal class ExportContext
|
|||||||
|
|
||||||
public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id);
|
public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id);
|
||||||
|
|
||||||
public Color? TryGetUserColor(Snowflake id)
|
public IReadOnlyList<Role> GetUserRoles(Snowflake id) => TryGetMember(id)?
|
||||||
{
|
.RoleIds
|
||||||
var member = TryGetMember(id);
|
.Select(TryGetRole)
|
||||||
|
.WhereNotNull()
|
||||||
|
.OrderByDescending(r => r.Position)
|
||||||
|
.ToArray() ?? Array.Empty<Role>();
|
||||||
|
|
||||||
var memberRoles = member?
|
public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id)
|
||||||
.RoleIds
|
.Where(r => r.Color is not null)
|
||||||
.Select(TryGetRole)
|
.Select(r => r.Color)
|
||||||
.WhereNotNull()
|
.FirstOrDefault();
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
return memberRoles?
|
|
||||||
.Where(r => r.Color is not null)
|
|
||||||
.OrderByDescending(r => r.Position)
|
|
||||||
.Select(r => r.Color)
|
|
||||||
.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
|
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -117,7 +112,7 @@ internal class ExportContext
|
|||||||
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
||||||
|
|
||||||
// Prefer relative paths so that the output files can be copied around without breaking references.
|
// Prefer relative paths so that the output files can be copied around without breaking references.
|
||||||
// If the assets path is outside of the export directory, use an absolute path instead.
|
// If the asset directory is outside of the export directory, use an absolute path instead.
|
||||||
var optimalFilePath =
|
var optimalFilePath =
|
||||||
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
||||||
relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
|
relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ public partial class ExportRequest
|
|||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
// Guild and channel names
|
// Guild and channel names
|
||||||
buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]");
|
buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]");
|
||||||
|
|
||||||
// Date range
|
// Date range
|
||||||
if (after is not null || before is not null)
|
if (after is not null || before is not null)
|
||||||
@@ -148,12 +148,12 @@ public partial class ExportRequest
|
|||||||
{
|
{
|
||||||
"%g" => guild.Id.ToString(),
|
"%g" => guild.Id.ToString(),
|
||||||
"%G" => guild.Name,
|
"%G" => guild.Name,
|
||||||
"%t" => channel.Category.Id.ToString(),
|
"%t" => channel.Parent?.Id.ToString() ?? "",
|
||||||
"%T" => channel.Category.Name,
|
"%T" => channel.Parent?.Name ?? "",
|
||||||
"%c" => channel.Id.ToString(),
|
"%c" => channel.Id.ToString(),
|
||||||
"%C" => channel.Name,
|
"%C" => channel.Name,
|
||||||
"%p" => channel.Position?.ToString() ?? "0",
|
"%p" => channel.Position?.ToString() ?? "0",
|
||||||
"%P" => channel.Category.Position?.ToString() ?? "0",
|
"%P" => channel.Parent?.Position?.ToString() ?? "0",
|
||||||
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
|
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
||||||
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
var symbol = channel?.Kind.IsVoice() == true ? "🔊" : "#";
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer.Append(
|
_buffer.Append(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.IO;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -48,6 +49,9 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
|
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
|
||||||
_writer.WriteBoolean("isBot", user.IsBot);
|
_writer.WriteBoolean("isBot", user.IsBot);
|
||||||
|
|
||||||
|
_writer.WritePropertyName("roles");
|
||||||
|
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
||||||
|
|
||||||
_writer.WriteString(
|
_writer.WriteString(
|
||||||
"avatarUrl",
|
"avatarUrl",
|
||||||
await Context.ResolveAssetUrlAsync(
|
await Context.ResolveAssetUrlAsync(
|
||||||
@@ -60,6 +64,28 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteRolesAsync(
|
||||||
|
IReadOnlyList<Role> roles,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartArray();
|
||||||
|
|
||||||
|
foreach (var role in roles)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", role.Id.ToString());
|
||||||
|
_writer.WriteString("name", role.Name);
|
||||||
|
_writer.WriteString("color", role.Color?.ToHex());
|
||||||
|
_writer.WriteNumber("position", role.Position);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteEmbedAuthorAsync(
|
private async ValueTask WriteEmbedAuthorAsync(
|
||||||
EmbedAuthor embedAuthor,
|
EmbedAuthor embedAuthor,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -215,8 +241,8 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteStartObject("channel");
|
_writer.WriteStartObject("channel");
|
||||||
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
||||||
_writer.WriteString("type", Context.Request.Channel.Kind.ToString());
|
_writer.WriteString("type", Context.Request.Channel.Kind.ToString());
|
||||||
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString());
|
_writer.WriteString("categoryId", Context.Request.Channel.Parent?.Id.ToString());
|
||||||
_writer.WriteString("category", Context.Request.Channel.Category.Name);
|
_writer.WriteString("category", Context.Request.Channel.Category);
|
||||||
_writer.WriteString("name", Context.Request.Channel.Name);
|
_writer.WriteString("name", Context.Request.Channel.Name);
|
||||||
_writer.WriteString("topic", Context.Request.Channel.Topic);
|
_writer.WriteString("topic", Context.Request.Channel.Topic);
|
||||||
|
|
||||||
@@ -236,6 +262,9 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteString("before", Context.Request.Before?.ToDate());
|
_writer.WriteString("before", Context.Request.Before?.ToDate());
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
_writer.WriteString("exportedAt", System.DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
// Message array (start)
|
// Message array (start)
|
||||||
_writer.WriteStartArray("messages");
|
_writer.WriteStartArray("messages");
|
||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
|||||||
@@ -321,7 +321,7 @@
|
|||||||
|
|
||||||
foreach (var inviteCode in inviteCodes)
|
foreach (var inviteCode in inviteCodes)
|
||||||
{
|
{
|
||||||
var invite = await Context.Discord.TryGetGuildInviteAsync(inviteCode, CancellationToken);
|
var invite = await Context.Discord.TryGetInviteAsync(inviteCode, CancellationToken);
|
||||||
if (invite is null)
|
if (invite is null)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Markdown;
|
using DiscordChatExporter.Core.Markdown;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
@@ -71,7 +72,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
_buffer.Append($"#{name}");
|
_buffer.Append($"#{name}");
|
||||||
|
|
||||||
// Voice channel marker
|
// Voice channel marker
|
||||||
if (channel?.IsVoice == true)
|
if (channel?.Kind.IsVoice() == true)
|
||||||
_buffer.Append(" [voice]");
|
_buffer.Append(" [voice]");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Role)
|
else if (mention.Kind == MentionKind.Role)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
await _writer.WriteLineAsync(new string('=', 62));
|
await _writer.WriteLineAsync(new string('=', 62));
|
||||||
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
|
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
|
||||||
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
|
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}");
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1004,7 +1004,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="preamble__entries-container">
|
<div class="preamble__entries-container">
|
||||||
<div class="preamble__entry">@Context.Request.Guild.Name</div>
|
<div class="preamble__entry">@Context.Request.Guild.Name</div>
|
||||||
<div class="preamble__entry">@Context.Request.Channel.Category.Name / @Context.Request.Channel.Name</div>
|
<div class="preamble__entry">@Context.Request.Channel.Category / @Context.Request.Channel.Name</div>
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
@if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ public static class BinaryExtensions
|
|||||||
{
|
{
|
||||||
var buffer = new StringBuilder(2 * data.Length);
|
var buffer = new StringBuilder(2 * data.Length);
|
||||||
|
|
||||||
foreach (var t in data)
|
foreach (var b in data)
|
||||||
buffer.Append(t.ToString("X2"));
|
buffer.Append(b.ToString("X2"));
|
||||||
|
|
||||||
return buffer.ToString();
|
return buffer.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ public static class StringExtensions
|
|||||||
? str[..charCount]
|
? str[..charCount]
|
||||||
: str;
|
: str;
|
||||||
|
|
||||||
|
public static string ToSpaceSeparatedWords(this string str)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder(str.Length * 2);
|
||||||
|
|
||||||
|
foreach (var c in str)
|
||||||
|
{
|
||||||
|
if (char.IsUpper(c) && builder.Length > 0)
|
||||||
|
builder.Append(' ');
|
||||||
|
|
||||||
|
builder.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
public static IEnumerable<Rune> GetRunes(this string str)
|
public static IEnumerable<Rune> GetRunes(this string str)
|
||||||
{
|
{
|
||||||
var lastIndex = 0;
|
var lastIndex = 0;
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if !DEBUG
|
#if !DEBUG
|
||||||
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
|
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
|
||||||
{
|
{
|
||||||
base.OnUnhandledException(e);
|
base.OnUnhandledException(args);
|
||||||
|
|
||||||
MessageBox.Show(
|
MessageBox.Show(
|
||||||
e.Exception.ToString(),
|
args.Exception.ToString(),
|
||||||
"Error occured",
|
"Error occured",
|
||||||
MessageBoxButton.OK,
|
MessageBoxButton.OK,
|
||||||
MessageBoxImage.Error
|
MessageBoxImage.Error
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Cogwheel" Version="2.0.2" />
|
<PackageReference Include="Cogwheel" Version="2.0.3" />
|
||||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
|
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
|
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
|
||||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
||||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
||||||
<PackageReference Include="Onova" Version="2.6.9" />
|
<PackageReference Include="Onova" Version="2.6.10" />
|
||||||
<PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" />
|
<PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="Stylet" Version="1.3.6" />
|
<PackageReference Include="Stylet" Version="1.3.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -46,10 +46,13 @@ public partial class SettingsService : SettingsBase
|
|||||||
public override void Save()
|
public override void Save()
|
||||||
{
|
{
|
||||||
// Clear the token if it's not supposed to be persisted
|
// Clear the token if it's not supposed to be persisted
|
||||||
|
var lastToken = LastToken;
|
||||||
if (!IsTokenPersisted)
|
if (!IsTokenPersisted)
|
||||||
LastToken = null;
|
LastToken = null;
|
||||||
|
|
||||||
base.Save();
|
base.Save();
|
||||||
|
|
||||||
|
LastToken = lastToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ public class DashboardViewModel : PropertyChangedBase
|
|||||||
|
|
||||||
public string? Token { get; set; }
|
public string? Token { get; set; }
|
||||||
|
|
||||||
public bool IsTokenSet => !string.IsNullOrWhiteSpace(Token);
|
|
||||||
|
|
||||||
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? ChannelsByGuild { get; set; }
|
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? ChannelsByGuild { get; set; }
|
||||||
|
|
||||||
public IReadOnlyList<Guild>? AvailableGuilds => ChannelsByGuild?.Keys.ToArray();
|
public IReadOnlyList<Guild>? AvailableGuilds => ChannelsByGuild?.Keys.ToArray();
|
||||||
@@ -67,8 +65,15 @@ public class DashboardViewModel : PropertyChangedBase
|
|||||||
|
|
||||||
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
||||||
|
|
||||||
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
this.Bind(
|
||||||
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
o => o.IsBusy,
|
||||||
|
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
|
||||||
|
);
|
||||||
|
|
||||||
|
Progress.Bind(
|
||||||
|
o => o.Current,
|
||||||
|
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnViewLoaded()
|
public void OnViewLoaded()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
||||||
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
|
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
|
||||||
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
|
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
|
||||||
|
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
|
||||||
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:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core"
|
xmlns:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core"
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
<!-- Sort guild channels by position -->
|
<!-- Sort guild channels by position -->
|
||||||
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
||||||
<CollectionViewSource.GroupDescriptions>
|
<CollectionViewSource.GroupDescriptions>
|
||||||
<PropertyGroupDescription PropertyName="Category.Name" />
|
<PropertyGroupDescription PropertyName="Category" />
|
||||||
</CollectionViewSource.GroupDescriptions>
|
</CollectionViewSource.GroupDescriptions>
|
||||||
<CollectionViewSource.SortDescriptions>
|
<CollectionViewSource.SortDescriptions>
|
||||||
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
|
||||||
@@ -75,38 +76,29 @@
|
|||||||
Kind="Key" />
|
Kind="Key" />
|
||||||
|
|
||||||
<!-- Token value -->
|
<!-- Token value -->
|
||||||
<TextBox
|
<controls:RevealablePasswordBox
|
||||||
x:Name="TokenValueTextBox"
|
x:Name="TokenValueTextBox"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="0,6,6,8"
|
Margin="0,6,6,8"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
materialDesign:HintAssist.Hint="Token"
|
materialDesign:HintAssist.Hint="Token"
|
||||||
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
|
||||||
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
|
FontFamily="Consolas"
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}">
|
Password="{Binding Token, UpdateSourceTrigger=PropertyChanged}">
|
||||||
<TextBox.Style>
|
<controls:RevealablePasswordBox.Style>
|
||||||
<Style BasedOn="{StaticResource {x:Type TextBox}}" TargetType="{x:Type TextBox}">
|
<Style TargetType="{x:Type controls:RevealablePasswordBox}">
|
||||||
<Style.Triggers>
|
<Style.Triggers>
|
||||||
<!-- Blur the token when it's out of focus -->
|
<DataTrigger Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
|
||||||
<MultiDataTrigger>
|
<Setter Property="IsRevealed" Value="True" />
|
||||||
<MultiDataTrigger.Conditions>
|
</DataTrigger>
|
||||||
<Condition Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="False" />
|
<DataTrigger Binding="{Binding IsKeyboardFocusWithin, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
|
||||||
<Condition Binding="{Binding IsFocused, RelativeSource={RelativeSource Self}}" Value="False" />
|
<Setter Property="IsRevealed" Value="True" />
|
||||||
<!-- Don't blur if the token is not set, so the user can see the hint text -->
|
</DataTrigger>
|
||||||
<Condition Binding="{Binding IsTokenSet}" Value="True" />
|
|
||||||
</MultiDataTrigger.Conditions>
|
|
||||||
<Setter Property="Effect">
|
|
||||||
<Setter.Value>
|
|
||||||
<BlurEffect Radius="12" />
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</MultiDataTrigger>
|
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</TextBox.Style>
|
</controls:RevealablePasswordBox.Style>
|
||||||
</TextBox>
|
</controls:RevealablePasswordBox>
|
||||||
|
|
||||||
<!-- Pull data button -->
|
<!-- Pull data button -->
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="DiscordChatExporter.Gui.Views.Controls.RevealablePasswordBox"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
|
x:Name="Root"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
<Grid>
|
||||||
|
<TextBox
|
||||||
|
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||||
|
BorderThickness="{Binding BorderThickness, ElementName=Root}"
|
||||||
|
Text="{Binding Password, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
Visibility="{Binding IsRevealed, ElementName=Root, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
|
||||||
|
<PasswordBox
|
||||||
|
materialDesign:PasswordBoxAssist.Password="{Binding Password, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
|
||||||
|
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||||
|
BorderThickness="{Binding BorderThickness, ElementName=Root}"
|
||||||
|
IsEnabled="False"
|
||||||
|
Visibility="{Binding IsRevealed, ElementName=Root, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}" />
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Views.Controls;
|
||||||
|
|
||||||
|
public partial class RevealablePasswordBox
|
||||||
|
{
|
||||||
|
public static readonly DependencyProperty PasswordProperty = DependencyProperty.Register(
|
||||||
|
nameof(Password),
|
||||||
|
typeof(string),
|
||||||
|
typeof(RevealablePasswordBox),
|
||||||
|
new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)
|
||||||
|
);
|
||||||
|
|
||||||
|
public static readonly DependencyProperty IsRevealedProperty = DependencyProperty.Register(
|
||||||
|
nameof(IsRevealed),
|
||||||
|
typeof(bool),
|
||||||
|
typeof(RevealablePasswordBox),
|
||||||
|
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.None)
|
||||||
|
);
|
||||||
|
|
||||||
|
public string Password
|
||||||
|
{
|
||||||
|
get => (string)GetValue(PasswordProperty);
|
||||||
|
set => SetValue(PasswordProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRevealed
|
||||||
|
{
|
||||||
|
get => (bool)GetValue(IsRevealedProperty);
|
||||||
|
set => SetValue(IsRevealedProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RevealablePasswordBox()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
FontWeight="Light"
|
FontWeight="Light"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
<Run Text="{Binding Channels[0].Category.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Category.Name, Mode=OneWay}" />
|
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
|
||||||
<Run Text="/" />
|
<Run Text="/" />
|
||||||
<Run
|
<Run
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Margin="16,16,16,8"
|
Margin="16"
|
||||||
FontSize="19"
|
FontSize="19"
|
||||||
FontWeight="Light"
|
FontWeight="Light"
|
||||||
Text="Settings" />
|
Text="Settings" />
|
||||||
@@ -60,10 +60,10 @@
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
x:Name="DarkModeToggleButton"
|
x:Name="DarkModeToggleButton"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Checked="DarkModeToggleButton_Checked"
|
Checked="DarkModeToggleButton_OnChecked"
|
||||||
DockPanel.Dock="Right"
|
DockPanel.Dock="Right"
|
||||||
IsChecked="{Binding IsDarkModeEnabled}"
|
IsChecked="{Binding IsDarkModeEnabled}"
|
||||||
Unchecked="DarkModeToggleButton_Unchecked" />
|
Unchecked="DarkModeToggleButton_OnUnchecked" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Persist token -->
|
<!-- Persist token -->
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip="Save last used token in a file so that it can be persisted between sessions">
|
ToolTip="Save the last used token to a file so that it can be persisted between sessions">
|
||||||
<TextBlock
|
<TextBlock
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
DockPanel.Dock="Left"
|
DockPanel.Dock="Left"
|
||||||
@@ -132,13 +132,13 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Save button -->
|
<!-- Close button -->
|
||||||
<Button
|
<Button
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
Margin="16"
|
Margin="16"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
Command="{s:Action Close}"
|
Command="{s:Action Close}"
|
||||||
Content="SAVE"
|
Content="CLOSE"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ public partial class SettingsView
|
|||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
|
private void DarkModeToggleButton_OnChecked(object sender, RoutedEventArgs args) =>
|
||||||
App.SetDarkTheme();
|
App.SetDarkTheme();
|
||||||
|
|
||||||
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
|
private void DarkModeToggleButton_OnUnchecked(object sender, RoutedEventArgs args) =>
|
||||||
App.SetLightTheme();
|
App.SetLightTheme();
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
# DiscordChatExporter
|
# DiscordChatExporter
|
||||||
|
|
||||||
|
[](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
|
||||||
[](https://tyrrrz.me/ukraine)
|
[](https://tyrrrz.me/ukraine)
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
||||||
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
||||||
@@ -7,10 +8,13 @@
|
|||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
[](https://hub.docker.com/r/tyrrrz/discordchatexporter)
|
[](https://hub.docker.com/r/tyrrrz/discordchatexporter)
|
||||||
[](https://discord.gg/2SUWKFnHSm)
|
[](https://discord.gg/2SUWKFnHSm)
|
||||||
[](https://tyrrrz.me/donate)
|
|
||||||
[](https://twitter.com/tyrrrz/status/1495972128977571848)
|
[](https://twitter.com/tyrrrz/status/1495972128977571848)
|
||||||
|
|
||||||
> 🟡 **Project status**: maintenance mode<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="99999" align="center">Development of this project is entirely funded by the community. <b><a href="https://tyrrrz.me/donate">Consider donating to support!</a></b></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="favicon.png" alt="Icon" />
|
<img src="favicon.png" alt="Icon" />
|
||||||
|
|||||||
Reference in New Issue
Block a user