Compare commits

...

12 Commits

Author SHA1 Message Date
Oleksii Holub 6c484d1f88 Update version 2022-01-27 18:36:28 +02:00
Oleksii Holub c36b8df8e7 Update NuGet packages 2022-01-27 18:25:21 +02:00
Dhananjay-JSR 0408096746 Update token extraction guide (#791) 2022-01-25 15:11:22 -08:00
Tyrrrz 40d833cd57 Update my name to match correct spelling 2022-01-15 03:27:19 +02:00
Tyrrrz 0de1f47310 More parsing leniency
Closes #767
2022-01-09 22:24:02 +02:00
Tyrrrz e31ff55e33 Handle invalid dates more gracefully
Closes #766
2022-01-05 18:54:23 +02:00
Tyrrrz 81525a1076 Update license 2022-01-05 18:31:16 +02:00
Tyrrrz 65ab003ff5 Relax parsing rules for embed fields
Closes #765
2022-01-04 22:44:03 +02:00
Alexey Golub 2156c6cd0c Automatically detect token kind (#764) 2022-01-03 14:52:16 -08:00
Tyrrrz e97151cd19 Update NuGet packages 2022-01-03 00:36:10 +02:00
Tyrrrz 271a478a0e Minor GUI tweaks 2021-12-15 16:30:23 +02:00
Tyrrrz b40a88c4d3 Add border to settings view too 2021-12-15 16:13:48 +02:00
33 changed files with 297 additions and 308 deletions
+10 -1
View File
@@ -1,6 +1,15 @@
### v2.32 (15-Dec-2021)
- Token kind (user or bot) is now detected automatically. Removed the button to switch token kind in GUI. Option `-b|--bot` in CLI is now deprecated and does nothing.
- Updated user token extraction guide to reflect the fact that devtools are no longer accessible in the desktop version of Discord client. The recommended workaround is to open Discord in browser. (Thanks [@Dhananjay-JSR](https://github.com/Dhananjay-JSR))
- Fixed an issue which caused an error when parsing invalid date timestamps. Such timestamps are now rendered as "Invalid date", similarly to how the Discord client does it.
- Fixed an issue which caused an error when parsing certain embed fields.
- Fixed an issue which caused an error when parsing a mention to a user that has no username.
- [GUI] Minor visual fixes.
### v2.31.1 (15-Dec-2021) ### v2.31.1 (15-Dec-2021)
- Fixed an issue which caused an exception when parsing certain custom emoji reactions. - Fixed an issue which caused an error when parsing certain custom emoji reactions.
- [GUI] Improved user interface. - [GUI] Improved user interface.
### v2.31 (06-Dec-2021) ### v2.31 (06-Dec-2021)
+2 -2
View File
@@ -2,9 +2,9 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>2.31.1</Version> <Version>2.32</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors> <WarningsAsErrors>nullable</WarningsAsErrors>
@@ -15,7 +15,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="0.16.1" /> <PackageReference Include="AngleSharp" Version="0.16.1" />
<PackageReference Include="FluentAssertions" Version="6.2.0" /> <PackageReference Include="FluentAssertions" Version="6.4.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" /> <PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
@@ -36,8 +36,7 @@ public class ExportWrapperFixture : IDisposable
{ {
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { channelId }, ChannelIds = new[] { channelId },
ExportFormat = format, ExportFormat = format,
OutputPath = filePath OutputPath = filePath
@@ -22,24 +22,5 @@ internal static class Secrets
throw new InvalidOperationException("Discord token not provided for tests."); throw new InvalidOperationException("Discord token not provided for tests.");
}); });
private static readonly Lazy<bool> IsDiscordTokenBotLazy = new(() =>
{
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT");
if (!string.IsNullOrWhiteSpace(fromEnvironment))
return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase);
var secretFilePath = Path.Combine(
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
"DiscordTokenBot.secret"
);
if (File.Exists(secretFilePath))
return true;
return false;
});
public static string DiscordToken => DiscordTokenLazy.Value; public static string DiscordToken => DiscordTokenLazy.Value;
public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
} }
@@ -27,8 +27,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@@ -74,8 +73,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@@ -120,8 +118,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@@ -25,8 +25,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@@ -54,8 +53,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@@ -83,8 +81,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@@ -112,8 +109,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = filePath, OutputPath = filePath,
@@ -25,8 +25,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
@@ -50,8 +49,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
@@ -25,8 +25,7 @@ public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<T
// Act // Act
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
TokenValue = Secrets.DiscordToken, Token = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] { ChannelIds.SelfContainedTestCases }, ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
@@ -1,4 +1,5 @@
using System.Threading.Tasks; using System;
using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@@ -9,21 +10,14 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class TokenCommandBase : ICommand public abstract class TokenCommandBase : ICommand
{ {
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")] [CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
public string TokenValue { get; init; } = ""; public string Token { get; init; } = "";
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")] [CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "This option doesn't do anything. Kept for backwards compatibility.")]
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
public bool IsBotToken { get; init; } public bool IsBotToken { get; init; }
private AuthToken? _authToken;
private AuthToken AuthToken => _authToken ??= new AuthToken(
IsBotToken
? AuthTokenKind.Bot
: AuthTokenKind.User,
TokenValue
);
private DiscordClient? _discordClient; private DiscordClient? _discordClient;
protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken); protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
public abstract ValueTask ExecuteAsync(IConsole console); public abstract ValueTask ExecuteAsync(IConsole console);
} }
@@ -15,7 +15,7 @@ public class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get user token:"); console.Output.WriteLine("To get user token:");
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord in your web browser and login");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar"); console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
console.Output.WriteLine(" 4. Navigate to the Application tab"); console.Output.WriteLine(" 4. Navigate to the Application tab");
@@ -61,7 +61,7 @@ public class GuideCommand : ICommand
// Wiki link // Wiki link
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("For more information, check out the wiki:"); console.Output.WriteLine("If you have questions or issues, please refer to the wiki:");
using (console.WithForegroundColor(ConsoleColor.DarkCyan)) using (console.WithForegroundColor(ConsoleColor.DarkCyan))
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki"); console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
@@ -6,7 +6,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.0.6" /> <PackageReference Include="CliFx" Version="2.2.1" />
<PackageReference Include="Spectre.Console" Version="0.43.0" /> <PackageReference Include="Spectre.Console" Version="0.43.0" />
<PackageReference Include="Gress" Version="1.2.0" /> <PackageReference Include="Gress" Version="1.2.0" />
</ItemGroup> </ItemGroup>
@@ -1,12 +0,0 @@
using System.Net.Http.Headers;
namespace DiscordChatExporter.Core.Discord;
public record AuthToken(AuthTokenKind Kind, string Value)
{
public AuthenticationHeaderValue GetAuthenticationHeader() => Kind switch
{
AuthTokenKind.Bot => new AuthenticationHeaderValue("Bot", Value),
_ => new AuthenticationHeaderValue(Value)
};
}
@@ -36,7 +36,7 @@ public partial record Attachment
var url = json.GetProperty("url").GetNonWhiteSpaceString(); var url = json.GetProperty("url").GetNonWhiteSpaceString();
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull(); var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull(); var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
var fileName = json.GetProperty("filename").GetNonWhiteSpaceString(); var fileName = json.GetProperty("filename").GetNonNullString();
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes); var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
return new Attachment(id, url, fileName, width, height, fileSize); return new Attachment(id, url, fileName, width, height, fileSize);
@@ -11,8 +11,8 @@ public record EmbedField(
{ {
public static EmbedField Parse(JsonElement json) public static EmbedField Parse(JsonElement json)
{ {
var name = json.GetProperty("name").GetNonWhiteSpaceString(); var name = json.GetProperty("name").GetNonNullString();
var value = json.GetProperty("value").GetNonWhiteSpaceString(); var value = json.GetProperty("value").GetNonNullString();
var isInline = json.GetPropertyOrNull("inline")?.GetBooleanOrNull() ?? false; var isInline = json.GetPropertyOrNull("inline")?.GetBooleanOrNull() ?? false;
return new EmbedField(name, value, isInline); return new EmbedField(name, value, isInline);
@@ -23,7 +23,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
public static Guild Parse(JsonElement json) public static Guild Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonWhiteSpaceString(); var name = json.GetProperty("name").GetNonNullString();
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull(); var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
var iconUrl = !string.IsNullOrWhiteSpace(iconHash) var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
@@ -12,7 +12,7 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
public static Role Parse(JsonElement json) public static Role Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonWhiteSpaceString(); var name = json.GetProperty("name").GetNonNullString();
var position = json.GetProperty("position").GetInt32(); var position = json.GetProperty("position").GetInt32();
var color = json var color = json
@@ -38,7 +38,7 @@ public partial record User
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false; var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse); var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
var name = json.GetProperty("username").GetNonWhiteSpaceString(); var name = json.GetProperty("username").GetNonNullString();
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull(); var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash) var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@@ -18,10 +19,30 @@ namespace DiscordChatExporter.Core.Discord;
public class DiscordClient public class DiscordClient
{ {
private readonly AuthToken _token; private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute); private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
public DiscordClient(AuthToken token) => _token = token; private TokenKind _tokenKind = TokenKind.Unknown;
public DiscordClient(string token) => _token = token;
private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url,
bool isBot,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = isBot
? new AuthenticationHeaderValue("Bot", _token)
: new AuthenticationHeaderValue(_token);
return await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
);
}
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
@@ -29,14 +50,33 @@ public class DiscordClient
{ {
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken => return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); if (_tokenKind == TokenKind.User)
request.Headers.Authorization = _token.GetAuthenticationHeader(); return await GetResponseAsync(url, false, innerCancellationToken);
return await Http.Client.SendAsync( if (_tokenKind == TokenKind.Bot)
request, return await GetResponseAsync(url, true, innerCancellationToken);
HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken // Try to authenticate as user
); var userResponse = await GetResponseAsync(url, false, innerCancellationToken);
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.User;
return userResponse;
}
userResponse.Dispose();
// Otherwise, try to authenticate as bot
var botResponse = await GetResponseAsync(url, true, innerCancellationToken);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.Bot;
return botResponse;
}
// The token is probably invalid altogether.
// Return the last response anyway, upstream should handle the error.
return botResponse;
}, cancellationToken); }, cancellationToken);
} }
@@ -1,7 +1,8 @@
namespace DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Core.Discord;
public enum AuthTokenKind public enum TokenKind
{ {
Unknown,
User, User,
Bot Bot
} }
@@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.0" /> <PackageReference Include="MiniRazor.CodeGen" Version="2.2.0" />
<PackageReference Include="Polly" Version="7.2.2" /> <PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
</ItemGroup> </ItemGroup>
@@ -17,8 +17,6 @@ public class ChannelExporter
public ChannelExporter(DiscordClient discord) => _discord = discord; public ChannelExporter(DiscordClient discord) => _discord = discord;
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
public async ValueTask ExportChannelAsync( public async ValueTask ExportChannelAsync(
ExportRequest request, ExportRequest request,
IProgress<double>? progress = null, IProgress<double>? progress = null,
@@ -159,12 +159,18 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
{ {
var dateString = timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
: "Invalid date";
// Timestamp tooltips always use full date regardless of the configured format // Timestamp tooltips always use full date regardless of the configured format
var longDateString = timestamp.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt"); var longDateString = timestamp.Date is not null
? timestamp.Date.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt")
: "Invalid date";
_buffer _buffer
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">") .Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
.Append(HtmlEncode(_context.FormatDate(timestamp.Value))) .Append(HtmlEncode(dateString))
.Append("</span>"); .Append("</span>");
return base.VisitUnixTimestamp(timestamp); return base.VisitUnixTimestamp(timestamp);
@@ -73,7 +73,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
{ {
_buffer.Append( _buffer.Append(
_context.FormatDate(timestamp.Value) timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
: "Invalid date"
); );
return base.VisitUnixTimestamp(timestamp); return base.VisitUnixTimestamp(timestamp);
@@ -235,7 +235,7 @@ internal static partial class MarkdownParser
// Capture <t:12345678> or <t:12345678:R> // Capture <t:12345678> or <t:12345678:R>
private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<t:(\\d+)(?::\\w)?>", DefaultRegexOptions), new Regex("<t:(-?\\d+)(?::\\w)?>", DefaultRegexOptions),
(_, m) => (_, m) =>
{ {
// TODO: support formatting parameters // TODO: support formatting parameters
@@ -244,17 +244,19 @@ internal static partial class MarkdownParser
if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture,
out var offset)) out var offset))
{ {
return null; return new UnixTimestampNode(null);
} }
// Bound check try
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
if (offset < TimeSpan.MinValue.TotalSeconds || offset > TimeSpan.MaxValue.TotalSeconds)
{ {
return null; return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
}
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
// https://github.com/Tyrrrz/DiscordChatExporter/issues/766
catch (Exception ex) when (ex is ArgumentOutOfRangeException or OverflowException)
{
return new UnixTimestampNode(null);
} }
return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
} }
); );
@@ -2,4 +2,5 @@
namespace DiscordChatExporter.Core.Markdown; namespace DiscordChatExporter.Core.Markdown;
internal record UnixTimestampNode(DateTimeOffset Value) : MarkdownNode; // Null means invalid date
internal record UnixTimestampNode(DateTimeOffset? Date) : MarkdownNode;
@@ -17,12 +17,12 @@
<PackageReference Include="MaterialDesignColors" Version="2.0.4" /> <PackageReference Include="MaterialDesignColors" Version="2.0.4" />
<PackageReference Include="MaterialDesignThemes" Version="4.3.0" /> <PackageReference Include="MaterialDesignThemes" Version="4.3.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.0" /> <PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.2" /> <PackageReference Include="Onova" Version="2.6.2" />
<PackageReference Include="Stylet" Version="1.3.6" /> <PackageReference Include="Stylet" Version="1.3.6" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" PrivateAssets="all" /> <PackageReference Include="PropertyChanged.Fody" Version="3.4.0" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.0.2" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.0.3" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,5 +1,4 @@
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting;
using Tyrrrz.Settings; using Tyrrrz.Settings;
namespace DiscordChatExporter.Gui.Services; namespace DiscordChatExporter.Gui.Services;
@@ -18,7 +17,7 @@ public class SettingsService : SettingsManager
public bool ShouldReuseMedia { get; set; } public bool ShouldReuseMedia { get; set; }
public AuthToken? LastToken { get; set; } public string? LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
@@ -25,6 +25,8 @@ public class RootViewModel : Screen
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;
private readonly UpdateService _updateService; private readonly UpdateService _updateService;
private DiscordClient? _discord;
public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5)); public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
public IProgressManager ProgressManager { get; } = new ProgressManager(); public IProgressManager ProgressManager { get; } = new ProgressManager();
@@ -33,9 +35,7 @@ public class RootViewModel : Screen
public bool IsProgressIndeterminate { get; private set; } public bool IsProgressIndeterminate { get; private set; }
public bool IsBotToken { get; set; } public string? Token { get; set; }
public string? TokenValue { get; set; }
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; } private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
@@ -111,8 +111,7 @@ public class RootViewModel : Screen
if (_settingsService.LastToken is not null) if (_settingsService.LastToken is not null)
{ {
IsBotToken = _settingsService.LastToken.Kind == AuthTokenKind.Bot; Token = _settingsService.LastToken;
TokenValue = _settingsService.LastToken.Value;
} }
if (_settingsService.IsDarkModeEnabled) if (_settingsService.IsDarkModeEnabled)
@@ -144,7 +143,7 @@ public class RootViewModel : Screen
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl); public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels => public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue); !IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PopulateGuildsAndChannels() public async void PopulateGuildsAndChannels()
{ {
@@ -152,15 +151,10 @@ public class RootViewModel : Screen
try try
{ {
var tokenValue = TokenValue?.Trim('"', ' '); var token = Token?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(tokenValue)) if (string.IsNullOrWhiteSpace(token))
return; return;
var token = new AuthToken(
IsBotToken ? AuthTokenKind.Bot : AuthTokenKind.User,
tokenValue
);
_settingsService.LastToken = token; _settingsService.LastToken = token;
var discord = new DiscordClient(token); var discord = new DiscordClient(token);
@@ -172,6 +166,7 @@ public class RootViewModel : Screen
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray(); guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
} }
_discord = discord;
GuildChannelMap = guildChannelMap; GuildChannelMap = guildChannelMap;
SelectedGuild = guildChannelMap.Keys.FirstOrDefault(); SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
} }
@@ -191,21 +186,24 @@ public class RootViewModel : Screen
} }
public bool CanExportChannels => public bool CanExportChannels =>
!IsBusy && SelectedGuild is not null && SelectedChannels is not null && SelectedChannels.Any(); !IsBusy &&
_discord is not null &&
SelectedGuild is not null &&
SelectedChannels is not null &&
SelectedChannels.Any();
public async void ExportChannels() public async void ExportChannels()
{ {
try try
{ {
var token = _settingsService.LastToken; if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return; return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels); var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true) if (await _dialogManager.ShowDialogAsync(dialog) != true)
return; return;
var exporter = new ChannelExporter(token); var exporter = new ChannelExporter(_discord);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count); var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0; var successfulExportCount = 0;
@@ -35,7 +35,7 @@
BorderThickness="0,1"> BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock <TextBlock
Margin="16,0,16,16" Margin="16,8"
Text="{Binding Message}" Text="{Binding Message}"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</ScrollViewer> </ScrollViewer>
@@ -24,126 +24,129 @@
FontWeight="Light" FontWeight="Light"
Text="Settings" /> Text="Settings" />
<ScrollViewer <Border
Grid.Row="1" Grid.Row="1"
HorizontalScrollBarVisibility="Disabled" Padding="0,8"
VerticalScrollBarVisibility="Auto"> BorderBrush="{DynamicResource MaterialDesignDivider}"
<StackPanel> BorderThickness="0,1">
<!-- Auto-updates --> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<DockPanel <StackPanel>
Margin="16,8" <!-- Auto-updates -->
Background="Transparent" <DockPanel
LastChildFill="False" Margin="16,8"
ToolTip="Perform automatic updates on every launch"> Background="Transparent"
<TextBlock LastChildFill="False"
VerticalAlignment="Center" ToolTip="Perform automatic updates on every launch">
DockPanel.Dock="Left"
Text="Auto-updates" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
<!-- Dark mode -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Use darker colors in the UI">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Dark mode" />
<ToggleButton
x:Name="DarkModeToggleButton"
VerticalAlignment="Center"
Checked="DarkModeToggleButton_Checked"
DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" />
</DockPanel>
<!-- Persist token -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Save last used token in a file so that it can be persisted between sessions">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Save token" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsTokenPersisted}" />
</DockPanel>
<!-- Reuse media -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Reuse already existing media content to skip redundant downloads">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Reuse downloaded media" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding ShouldReuseMedia}" />
</DockPanel>
<!-- Date format -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Format used when writing dates (uses .NET date formatting rules)">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Date format" />
<TextBox
Width="150"
VerticalAlignment="Center"
DockPanel.Dock="Right"
Text="{Binding DateFormat}" />
</DockPanel>
<!-- Parallel limit -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="How many channels can be exported at the same time">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Parallel limit"
TextAlignment="Right" />
<StackPanel
VerticalAlignment="Center"
DockPanel.Dock="Right"
Orientation="Horizontal">
<TextBlock <TextBlock
Margin="10,0"
VerticalAlignment="Center" VerticalAlignment="Center"
FontWeight="SemiBold" DockPanel.Dock="Left"
Text="{Binding ParallelLimit}" /> Text="Auto-updates" />
<Slider <ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
<!-- Dark mode -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Use darker colors in the UI">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Dark mode" />
<ToggleButton
x:Name="DarkModeToggleButton"
VerticalAlignment="Center"
Checked="DarkModeToggleButton_Checked"
DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" />
</DockPanel>
<!-- Persist token -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Save last used token in a file so that it can be persisted between sessions">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Save token" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsTokenPersisted}" />
</DockPanel>
<!-- Reuse media -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Reuse already existing media content to skip redundant downloads">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Reuse downloaded media" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding ShouldReuseMedia}" />
</DockPanel>
<!-- Date format -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Format used when writing dates (uses .NET date formatting rules)">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Date format" />
<TextBox
Width="150" Width="150"
VerticalAlignment="Center" VerticalAlignment="Center"
Maximum="10" DockPanel.Dock="Right"
Minimum="1" Text="{Binding DateFormat}" />
Style="{DynamicResource MaterialDesignThinSlider}" </DockPanel>
Value="{Binding ParallelLimit}" />
</StackPanel> <!-- Parallel limit -->
</DockPanel> <DockPanel
</StackPanel> Margin="16,8"
</ScrollViewer> Background="Transparent"
LastChildFill="False"
ToolTip="How many channels can be exported at the same time">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Parallel limit"
TextAlignment="Right" />
<StackPanel
VerticalAlignment="Center"
DockPanel.Dock="Right"
Orientation="Horizontal">
<TextBlock
Margin="10,0"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding ParallelLimit}" />
<Slider
Width="150"
VerticalAlignment="Center"
Maximum="10"
Minimum="1"
Style="{DynamicResource MaterialDesignThinSlider}"
Value="{Binding ParallelLimit}" />
</StackPanel>
</DockPanel>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Save button --> <!-- Save button -->
<Button <Button
+44 -66
View File
@@ -64,39 +64,28 @@
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Token type --> <!-- Token icon -->
<ToggleButton <materialDesign:PackIcon
Grid.Column="0" Grid.Column="0"
Margin="6" Width="24"
IsChecked="{Binding IsBotToken}" Height="24"
Style="{DynamicResource MaterialDesignFlatActionToggleButton}" Margin="8"
ToolTip="Switch between user token and bot token"> VerticalAlignment="Center"
<ToggleButton.Content> Foreground="{DynamicResource PrimaryHueMidBrush}"
<materialDesign:PackIcon Kind="Password" />
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
<!-- Token value --> <!-- Token value -->
<TextBox <TextBox
x:Name="TokenValueTextBox" x:Name="TokenValueTextBox"
Grid.Column="1" Grid.Column="1"
Margin="2,6,6,7" Margin="0,6,6,8"
VerticalAlignment="Bottom" VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token" materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden" materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0" materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0" BorderThickness="0"
FontSize="16" FontSize="16"
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" /> Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button --> <!-- Pull data button -->
<Button <Button
@@ -152,12 +141,25 @@
</Style> </Style>
</Grid.Resources> </Grid.Resources>
<!-- Placeholder / usage instructions --> <!-- Placeholder / usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token --> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <TextBlock Margin="32,16" FontSize="14">
<TextBlock FontSize="18" Text="Please provide your user token to authorize" /> <Run FontSize="18" Text="Please provide authentication token to continue" />
<TextBlock Margin="8,8,0,0" FontSize="14"> <LineBreak />
<Run Text="1. Open Discord" /> <LineBreak />
<!-- User token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using your personal account" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<Run FontWeight="SemiBold" Text="web browser" />
<Run Text="and login" />
<LineBreak /> <LineBreak />
<Run Text="2. Press" /> <Run Text="2. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" /> <Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
@@ -189,35 +191,20 @@
<Run Text="8. Copy the value of the" /> <Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" /> <Run FontWeight="SemiBold" Text="token" />
<Run Text="key" /> <Run Text="key" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="Automating user accounts is technically against TOS, use at your own risk." />
<LineBreak /> <LineBreak />
<Run Text="To authorize using bot token instead, click" /> <Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer> <InlineUIContainer>
<materialDesign:PackIcon <materialDesign:PackIcon
Margin="1,0,0,-3" Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" /> Kind="Robot" />
</InlineUIContainer> </InlineUIContainer>
<Run Text="in the text box above." /> <Run FontSize="16" Text="Authenticate as a bot" />
</TextBlock> <LineBreak />
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
<!-- For bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock
FontSize="18"
FontWeight="Light"
Text="Please provide your bot token to authorize" />
<TextBlock
Margin="8,8,0,0"
FontSize="14"
FontWeight="Light">
<Run Text="1. Open Discord developer portal" /> <Run Text="1. Open Discord developer portal" />
<LineBreak /> <LineBreak />
<Run Text="2. Open your application's settings" /> <Run Text="2. Open your application's settings" />
@@ -230,22 +217,13 @@
<Run FontWeight="SemiBold" Text="Token" /> <Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" /> <Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" /> <Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<LineBreak />
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> </ScrollViewer>
<Run Text="To authorize using user token instead, click" />
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-1"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
</Grid> </Grid>
<!-- Guilds and channels --> <!-- Guilds and channels -->
+1 -1
View File
@@ -1,5 +1,5 @@
DiscordChatExporter DiscordChatExporter
Copyright (C) 2017-2021 Alexey Golub Copyright (C) 2017-2022 Oleksii Holub
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by