Compare commits

...

22 Commits

Author SHA1 Message Date
Tyrrrz 5fc2fae28a Update version 2023-07-27 23:59:17 +03:00
Tyrrrz ddfbe51cfa Improve error reporting on unexpected HTTP status code 2023-07-25 18:59:05 +03:00
Tyrrrz bcf652edbe Don't throw when parent channel cannot be retrieved
Closes #1108
2023-07-23 16:04:53 +03:00
Tyrrrz 1f5407f032 Update version 2023-07-21 02:03:47 +03:00
Tyrrrz 83a63c2ed7 Update NuGet packages 2023-07-21 02:03:32 +03:00
Tyrrrz f33a0ac0a7 Update Onova 2023-07-18 22:12:26 +03:00
Tyrrrz 779b8984d7 Update readme 2023-07-17 00:47:30 +03:00
Tyrrrz 3e44a2e88d Re-add IHasId to Channel 2023-07-16 23:12:57 +03:00
Tyrrrz b44e6087be Update NuGet packages 2023-07-16 23:09:31 +03:00
Tyrrrz 49b96087d3 Clean up 2023-07-16 23:05:53 +03:00
Tyrrrz e175c93038 Refactor 2023-07-16 22:55:36 +03:00
Tyrrrz 0ee1107638 Make password box un-editable 2023-07-16 21:37:40 +03:00
Tyrrrz 81a6d363d1 Use favicon as avatar for Discord webhooks 2023-07-11 23:53:12 +03:00
Tyrrrz 90d71c5b9e Clean up 2023-07-10 20:40:32 +03:00
Adam Slatinský 5e9c7392db Add export timestamp in JSON format (#1102) 2023-07-09 18:22:09 +03:00
Tyrrrz c4137cf77e Hide token using password box 2023-07-09 18:13:41 +03:00
Tyrrrz 51192425b7 Expect channel parents to be null 2023-07-08 19:21:00 +03:00
William Unsworth 8776a6955b Better support for exporting threads (#1046) 2023-07-07 20:33:17 +03:00
Tyrrrz c69211797f Clean up 2023-07-03 18:35:28 +03:00
Adam Slatinský 768fb88c8c Export role details in JSON format (#1101) 2023-07-03 18:18:30 +03:00
Tyrrrz d4f62387a5 Add CLI option to include or exclude voice channels in exportall and exportguild 2023-06-23 21:23:55 +03:00
Tyrrrz bd4cfcdaf6 Update avatar resolving for users without discriminator 2023-06-21 22:05:08 +03:00
45 changed files with 405 additions and 322 deletions
+1
View File
@@ -151,5 +151,6 @@ jobs:
Content-Type: application/json; charset=UTF-8
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>"
}
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 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)
- General changes:
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>2.40</Version>
<Version>2.40.2</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
@@ -12,17 +12,17 @@
</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="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.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.UserSecrets" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.2" />
<PackageReference Include="ReflectionMagic" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" />
<PackageReference Include="ReflectionMagic" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
</ItemGroup>
@@ -48,23 +48,22 @@ public static class ExportWrapper
var fileName = channelId.ToString() + '.' + format.GetFileExtension();
var filePath = Path.Combine(DirPath, fileName);
// Lock separately for each channel and format
using (await Locker.LockAsync(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(new FakeConsole());
}
using var _ = await Locker.LockAsync(filePath);
using var console = 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(
@@ -194,7 +194,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
try
{
await progressContext.StartTaskAsync(
$"{channel.Category.Name} / {channel.Name}",
$"{channel.Category} / {channel.Name}",
async progress =>
{
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
@@ -253,7 +253,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
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))
await console.Error.WriteLineAsync(error);
@@ -290,7 +290,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Category.Id == channel.Id)
if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel);
}
@@ -1,5 +1,4 @@
using System;
using System.Globalization;
using System.Globalization;
using CliFx.Extensibility;
namespace DiscordChatExporter.Cli.Commands.Converters;
@@ -28,6 +28,12 @@ public class ExportAllCommand : ExportCommandBase
)]
public bool IncludeGuildChannels { get; init; } = true;
[CommandOption(
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"data-package",
Description =
@@ -97,6 +103,8 @@ public class ExportAllCommand : ExportCommandBase
channels.RemoveAll(c => c.Kind.IsDirect());
if (!IncludeGuildChannels)
channels.RemoveAll(c => c.Kind.IsGuild());
if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.Kind.IsVoice());
await base.ExecuteAsync(console, channels);
}
@@ -19,6 +19,12 @@ public class ExportGuildCommand : ExportCommandBase
)]
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)
{
await base.ExecuteAsync(console);
@@ -29,6 +35,7 @@ public class ExportGuildCommand : ExportCommandBase
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.Where(c => IncludeVoiceChannels || !c.Kind.IsVoice())
.ToArray();
await base.ExecuteAsync(console, channels);
@@ -32,7 +32,7 @@ public class GetChannelsCommand : DiscordCommandBase
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.OrderBy(c => c.Category.Position)
.OrderBy(c => c.Parent?.Position)
.ThenBy(c => c.Name)
.ToArray();
@@ -43,7 +43,7 @@ public class GetChannelsCommand : DiscordCommandBase
var threads = IncludeThreads
? (await Discord.GetGuildThreadsAsync(GuildId, cancellationToken)).OrderBy(c => c.Name).ToArray()
: Array.Empty<ChannelThread>();
: Array.Empty<Channel>();
foreach (var channel in channels)
{
@@ -58,22 +58,22 @@ public class GetChannelsCommand : DiscordCommandBase
// Channel category / name
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
.Select(t => t.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var thread in channelThreads)
foreach (var channelThread in channelThreads)
{
// Indent
await console.Output.WriteAsync(" * ");
// Thread ID
await console.Output.WriteAsync(
thread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
);
// Separator
@@ -82,7 +82,7 @@ public class GetChannelsCommand : DiscordCommandBase
// Thread name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteAsync($"Thread / {thread.Name}");
await console.Output.WriteAsync($"Thread / {channelThread.Name}");
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -90,7 +90,7 @@ public class GetChannelsCommand : DiscordCommandBase
// Thread status
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
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,
ChannelKind Kind,
Snowflake GuildId,
ChannelCategory Category,
Channel? Parent,
string Name,
int? Position,
string? IconUrl,
string? Topic,
bool IsActive,
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 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 kind = (ChannelKind)json.GetProperty("type").GetInt32();
@@ -32,8 +51,6 @@ public partial record Channel
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ??
Guild.DirectMessages.Id;
var category = categoryHint ?? ChannelCategory.CreateDefault(kind);
var name =
// Guild channel
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
@@ -60,6 +77,11 @@ public partial record Channel
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
var isActive = !json
.GetPropertyOrNull("thread_metadata")?
.GetPropertyOrNull("archived")?
.GetBooleanOrNull() ?? true;
var lastMessageId = json
.GetPropertyOrNull("last_message_id")?
.GetNonWhiteSpaceStringOrNull()?
@@ -69,11 +91,12 @@ public partial record Channel
id,
kind,
guildId,
category,
parent,
name,
position,
iconUrl,
topic,
isActive,
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) =>
!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}.png?size={size}";
public static string GetFallbackUserAvatarUrl(int discriminator) =>
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
public static string GetFallbackUserAvatarUrl(int index = 0) =>
$"https://cdn.discordapp.com/embed/avatars/{index}.png";
public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) =>
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(
Snowflake.Zero,
"Direct Messages",
ImageCdn.GetFallbackUserAvatarUrl(0)
ImageCdn.GetFallbackUserAvatarUrl()
);
public static Guild Parse(JsonElement json)
@@ -25,7 +25,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
.GetPropertyOrNull("icon")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl(0);
ImageCdn.GetFallbackUserAvatarUrl();
return new Guild(id, name, iconUrl);
}
@@ -20,7 +20,8 @@ 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)
{
@@ -45,12 +45,14 @@ public partial record User
var name = json.GetProperty("username").GetNonNullString();
var displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name;
var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6);
var avatarUrl =
json
.GetPropertyOrNull("avatar")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl(discriminator ?? 0);
ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
return new User(id, isBot, discriminator, name, displayName, avatarUrl);
}
@@ -108,7 +108,7 @@ public class DiscordClient
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.Bot;
throw DiscordChatExporterException.Unauthorized();
throw new DiscordChatExporterException("Authentication token is invalid.", true);
}
private async ValueTask<HttpResponseMessage> GetResponseAsync(
@@ -129,10 +129,26 @@ public class DiscordClient
{
throw response.StatusCode switch
{
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(),
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(),
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(url),
_ => DiscordChatExporterException.FailedHttpRequest(response)
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
"Authentication token is invalid.",
true
),
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))
.ToArray();
var categories = channelsJson
var parentsById = channelsJson
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory)
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
.Select((j, i) => Channel.Parse(j, null, i + 1))
.ToDictionary(j => j.Id);
// 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.
@@ -230,21 +246,19 @@ public class DiscordClient
foreach (var channelJson in channelsJson)
{
var parentId = channelJson
var parent = channelJson
.GetPropertyOrNull("parent_id")?
.GetNonWhiteSpaceStringOrNull();
.GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
var category = !string.IsNullOrWhiteSpace(parentId)
? categories.GetValueOrDefault(parentId)
: null;
yield return Channel.Parse(channelJson, category, position);
yield return Channel.Parse(channelJson, parent, position);
position++;
}
}
}
public async IAsyncEnumerable<ChannelThread> GetGuildThreadsAsync(
public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
@@ -264,13 +278,14 @@ public class DiscordClient
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray())
{
yield return ChannelThread.Parse(threadJson);
yield return Channel.Parse(threadJson, channel);
currentOffset++;
}
@@ -284,9 +299,19 @@ public class DiscordClient
{
// Active threads
{
var parentsById = channels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync($"guilds/{guildId}/threads/active", cancellationToken);
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)
@@ -299,7 +324,7 @@ public class DiscordClient
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
yield return ChannelThread.Parse(threadJson);
yield return Channel.Parse(threadJson, channel);
}
// Private archived threads
@@ -310,7 +335,7 @@ public class DiscordClient
);
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));
}
public async ValueTask<Invite?> TryGetGuildInviteAsync(
public async ValueTask<Invite?> TryGetInviteAsync(
string code,
CancellationToken cancellationToken = default)
{
@@ -348,23 +373,6 @@ public class DiscordClient
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(
Snowflake channelId,
CancellationToken cancellationToken = default)
@@ -376,11 +384,21 @@ public class DiscordClient
.GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse);
var category = parentId is not null
? await GetChannelCategoryAsync(parentId.Value, cancellationToken)
: null;
try
{
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(
@@ -4,11 +4,11 @@
<PackageReference Include="AsyncKeyedLock" Version="6.2.1" />
<PackageReference Include="Gress" Version="2.1.1" />
<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="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
<PackageReference Include="YoutubeExplode" Version="6.2.15" />
<PackageReference Include="YoutubeExplode" Version="6.3.0" />
</ItemGroup>
</Project>
@@ -1,9 +1,8 @@
using System;
using System.Net.Http;
namespace DiscordChatExporter.Core.Exceptions;
public partial class DiscordChatExporterException : Exception
public class DiscordChatExporterException : Exception
{
public bool IsFatal { get; }
@@ -12,33 +11,4 @@ public partial class DiscordChatExporterException : Exception
{
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
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
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
var context = new ExportContext(_discord, request);
@@ -50,6 +58,10 @@ public class ChannelExporter
// Throw if no messages were exported
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 filePath = Path.Combine(_workingDirPath, fileName);
using (await Locker.LockAsync(filePath, cancellationToken))
{
if (_pathCache.TryGetValue(url, out var cachedFilePath))
return cachedFilePath;
using var _ = await Locker.LockAsync(filePath, cancellationToken);
// Reuse existing files if we're allowed to
if (_reuse && File.Exists(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
}
});
if (_pathCache.TryGetValue(url, out var cachedFilePath))
return cachedFilePath;
// Reuse existing files if we're allowed to
if (_reuse && File.Exists(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
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
@@ -89,22 +89,17 @@ internal class ExportContext
public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id);
public Color? TryGetUserColor(Snowflake id)
{
var member = TryGetMember(id);
public IReadOnlyList<Role> GetUserRoles(Snowflake id) => TryGetMember(id)?
.RoleIds
.Select(TryGetRole)
.WhereNotNull()
.OrderByDescending(r => r.Position)
.ToArray() ?? Array.Empty<Role>();
var memberRoles = member?
.RoleIds
.Select(TryGetRole)
.WhereNotNull()
.ToArray();
return memberRoles?
.Where(r => r.Color is not null)
.OrderByDescending(r => r.Position)
.Select(r => r.Color)
.FirstOrDefault();
}
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)
{
@@ -117,7 +112,7 @@ internal class ExportContext
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
// 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 =
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
@@ -102,7 +102,7 @@ public partial class ExportRequest
var buffer = new StringBuilder();
// 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
if (after is not null || before is not null)
@@ -148,12 +148,12 @@ public partial class ExportRequest
{
"%g" => guild.Id.ToString(),
"%G" => guild.Name,
"%t" => channel.Category.Id.ToString(),
"%T" => channel.Category.Name,
"%t" => channel.Parent?.Id.ToString() ?? "",
"%T" => channel.Parent?.Name ?? "",
"%c" => channel.Id.ToString(),
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0",
"%P" => channel.Parent?.Position?.ToString() ?? "0",
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => before?.ToDate().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)
{
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
var symbol = channel?.IsVoice == true ? "🔊" : "#";
var symbol = channel?.Kind.IsVoice() == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel";
_buffer.Append(
@@ -1,4 +1,5 @@
using System.IO;
using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
@@ -48,6 +49,9 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot);
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
_writer.WriteString(
"avatarUrl",
await Context.ResolveAssetUrlAsync(
@@ -60,6 +64,28 @@ internal class JsonMessageWriter : MessageWriter
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(
EmbedAuthor embedAuthor,
CancellationToken cancellationToken = default)
@@ -215,8 +241,8 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject("channel");
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
_writer.WriteString("type", Context.Request.Channel.Kind.ToString());
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString());
_writer.WriteString("category", Context.Request.Channel.Category.Name);
_writer.WriteString("categoryId", Context.Request.Channel.Parent?.Id.ToString());
_writer.WriteString("category", Context.Request.Channel.Category);
_writer.WriteString("name", Context.Request.Channel.Name);
_writer.WriteString("topic", Context.Request.Channel.Topic);
@@ -236,6 +262,9 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("before", Context.Request.Before?.ToDate());
_writer.WriteEndObject();
// Timestamp
_writer.WriteString("exportedAt", System.DateTimeOffset.UtcNow);
// Message array (start)
_writer.WriteStartArray("messages");
await _writer.FlushAsync(cancellationToken);
@@ -321,7 +321,7 @@
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)
{
continue;
@@ -1,6 +1,7 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
@@ -71,7 +72,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"#{name}");
// Voice channel marker
if (channel?.IsVoice == true)
if (channel?.Kind.IsVoice() == true)
_buffer.Append(" [voice]");
}
else if (mention.Kind == MentionKind.Role)
@@ -193,7 +193,7 @@ internal class PlainTextMessageWriter : MessageWriter
{
await _writer.WriteLineAsync(new string('=', 62));
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))
{
@@ -1004,7 +1004,7 @@
</div>
<div class="preamble__entries-container">
<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))
{
@@ -8,8 +8,8 @@ public static class BinaryExtensions
{
var buffer = new StringBuilder(2 * data.Length);
foreach (var t in data)
buffer.Append(t.ToString("X2"));
foreach (var b in data)
buffer.Append(b.ToString("X2"));
return buffer.ToString();
}
@@ -16,6 +16,21 @@ public static class StringExtensions
? str[..charCount]
: 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)
{
var lastIndex = 0;
+3 -3
View File
@@ -31,12 +31,12 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
}
#if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
{
base.OnUnhandledException(e);
base.OnUnhandledException(args);
MessageBox.Show(
e.Exception.ToString(),
args.Exception.ToString(),
"Error occured",
MessageBoxButton.OK,
MessageBoxImage.Error
@@ -13,7 +13,7 @@
</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="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" />
@@ -21,7 +21,7 @@
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<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="Stylet" Version="1.3.6" />
</ItemGroup>
@@ -46,10 +46,13 @@ public partial class SettingsService : SettingsBase
public override void Save()
{
// Clear the token if it's not supposed to be persisted
var lastToken = LastToken;
if (!IsTokenPersisted)
LastToken = null;
base.Save();
LastToken = lastToken;
}
}
@@ -38,8 +38,6 @@ public class DashboardViewModel : PropertyChangedBase
public string? Token { get; set; }
public bool IsTokenSet => !string.IsNullOrWhiteSpace(Token);
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? ChannelsByGuild { get; set; }
public IReadOnlyList<Guild>? AvailableGuilds => ChannelsByGuild?.Keys.ToArray();
@@ -67,8 +65,15 @@ public class DashboardViewModel : PropertyChangedBase
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
this.Bind(
o => o.IsBusy,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
Progress.Bind(
o => o.Current,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
}
public void OnViewLoaded()
@@ -6,6 +6,7 @@
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core"
@@ -75,38 +76,29 @@
Kind="Key" />
<!-- Token value -->
<TextBox
<controls:RevealablePasswordBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="0,6,6,8"
VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontFamily="Consolas"
FontSize="16"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style>
<Style BasedOn="{StaticResource {x:Type TextBox}}" TargetType="{x:Type TextBox}">
Password="{Binding Token, UpdateSourceTrigger=PropertyChanged}">
<controls:RevealablePasswordBox.Style>
<Style TargetType="{x:Type controls:RevealablePasswordBox}">
<Style.Triggers>
<!-- Blur the token when it's out of focus -->
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="False" />
<Condition Binding="{Binding IsFocused, RelativeSource={RelativeSource Self}}" Value="False" />
<!-- Don't blur if the token is not set, so the user can see the hint text -->
<Condition Binding="{Binding IsTokenSet}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter Property="Effect">
<Setter.Value>
<BlurEffect Radius="12" />
</Setter.Value>
</Setter>
</MultiDataTrigger>
<DataTrigger Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
<Setter Property="IsRevealed" Value="True" />
</DataTrigger>
<DataTrigger Binding="{Binding IsKeyboardFocusWithin, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
<Setter Property="IsRevealed" Value="True" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBox.Style>
</TextBox>
</controls:RevealablePasswordBox.Style>
</controls:RevealablePasswordBox>
<!-- Pull data 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"
TextTrimming="CharacterEllipsis"
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
FontWeight="SemiBold"
@@ -19,7 +19,7 @@
<TextBlock
Grid.Row="0"
Margin="16,16,16,8"
Margin="16"
FontSize="19"
FontWeight="Light"
Text="Settings" />
@@ -60,10 +60,10 @@
<ToggleButton
x:Name="DarkModeToggleButton"
VerticalAlignment="Center"
Checked="DarkModeToggleButton_Checked"
Checked="DarkModeToggleButton_OnChecked"
DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" />
Unchecked="DarkModeToggleButton_OnUnchecked" />
</DockPanel>
<!-- Persist token -->
@@ -71,7 +71,7 @@
Margin="16,8"
Background="Transparent"
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
VerticalAlignment="Center"
DockPanel.Dock="Left"
@@ -132,13 +132,13 @@
</ScrollViewer>
</Border>
<!-- Save button -->
<!-- Close button -->
<Button
Grid.Row="2"
Margin="16"
HorizontalAlignment="Stretch"
Command="{s:Action Close}"
Content="SAVE"
Content="CLOSE"
IsCancel="True"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
@@ -9,9 +9,9 @@ public partial class SettingsView
InitializeComponent();
}
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
private void DarkModeToggleButton_OnChecked(object sender, RoutedEventArgs args) =>
App.SetDarkTheme();
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
private void DarkModeToggleButton_OnUnchecked(object sender, RoutedEventArgs args) =>
App.SetLightTheme();
}
+6 -2
View File
@@ -1,5 +1,6 @@
# DiscordChatExporter
[![Status](https://img.shields.io/badge/status-maintenance-ffd700.svg)](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
[![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://tyrrrz.me/ukraine)
[![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/DiscordChatExporter/main.yml?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
@@ -7,10 +8,13 @@
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Pulls](https://img.shields.io/docker/pulls/tyrrrz/discordchatexporter)](https://hub.docker.com/r/tyrrrz/discordchatexporter)
[![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm)
[![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate)
[![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](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">
<img src="favicon.png" alt="Icon" />