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)
- 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.
### v2.31 (06-Dec-2021)
+2 -2
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>2.31.1</Version>
<Version>2.32</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
@@ -15,7 +15,7 @@
<ItemGroup>
<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="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
@@ -36,8 +36,7 @@ public class ExportWrapperFixture : IDisposable
{
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId },
ExportFormat = format,
OutputPath = filePath
@@ -22,24 +22,5 @@ internal static class Secrets
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 bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
}
@@ -27,8 +27,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -74,8 +73,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -120,8 +118,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -25,8 +25,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -54,8 +53,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -83,8 +81,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -112,8 +109,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases },
ExportFormat = ExportFormat.Json,
OutputPath = filePath,
@@ -25,8 +25,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
@@ -50,8 +49,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
@@ -25,8 +25,7 @@ public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<T
// Act
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
@@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
@@ -9,21 +10,14 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class TokenCommandBase : ICommand
{
[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; }
private AuthToken? _authToken;
private AuthToken AuthToken => _authToken ??= new AuthToken(
IsBotToken
? AuthTokenKind.Bot
: AuthTokenKind.User,
TokenValue
);
private DiscordClient? _discordClient;
protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken);
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
public abstract ValueTask ExecuteAsync(IConsole console);
}
@@ -15,7 +15,7 @@ public class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.White))
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(" 3. Press Ctrl+Shift+M to toggle device toolbar");
console.Output.WriteLine(" 4. Navigate to the Application tab");
@@ -61,7 +61,7 @@ public class GuideCommand : ICommand
// Wiki link
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))
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
@@ -6,7 +6,7 @@
</PropertyGroup>
<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="Gress" Version="1.2.0" />
</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 width = json.GetPropertyOrNull("width")?.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);
return new Attachment(id, url, fileName, width, height, fileSize);
@@ -11,8 +11,8 @@ public record EmbedField(
{
public static EmbedField Parse(JsonElement json)
{
var name = json.GetProperty("name").GetNonWhiteSpaceString();
var value = json.GetProperty("value").GetNonWhiteSpaceString();
var name = json.GetProperty("name").GetNonNullString();
var value = json.GetProperty("value").GetNonNullString();
var isInline = json.GetPropertyOrNull("inline")?.GetBooleanOrNull() ?? false;
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)
{
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 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)
{
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 color = json
@@ -38,7 +38,7 @@ public partial record User
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
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 avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -18,10 +19,30 @@ namespace DiscordChatExporter.Core.Discord;
public class DiscordClient
{
private readonly AuthToken _token;
private readonly string _token;
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(
string url,
@@ -29,14 +50,33 @@ public class DiscordClient
{
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthenticationHeader();
if (_tokenKind == TokenKind.User)
return await GetResponseAsync(url, false, innerCancellationToken);
return await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
if (_tokenKind == TokenKind.Bot)
return await GetResponseAsync(url, true, 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);
}
@@ -1,7 +1,8 @@
namespace DiscordChatExporter.Core.Discord;
public enum AuthTokenKind
public enum TokenKind
{
Unknown,
User,
Bot
}
@@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="JsonExtensions" Version="1.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" />
</ItemGroup>
@@ -17,8 +17,6 @@ public class ChannelExporter
public ChannelExporter(DiscordClient discord) => _discord = discord;
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
public async ValueTask ExportChannelAsync(
ExportRequest request,
IProgress<double>? progress = null,
@@ -159,12 +159,18 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
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
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
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
.Append(HtmlEncode(_context.FormatDate(timestamp.Value)))
.Append(HtmlEncode(dateString))
.Append("</span>");
return base.VisitUnixTimestamp(timestamp);
@@ -73,7 +73,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
{
_buffer.Append(
_context.FormatDate(timestamp.Value)
timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
: "Invalid date"
);
return base.VisitUnixTimestamp(timestamp);
@@ -235,7 +235,7 @@ internal static partial class MarkdownParser
// Capture <t:12345678> or <t:12345678:R>
private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<t:(\\d+)(?::\\w)?>", DefaultRegexOptions),
new Regex("<t:(-?\\d+)(?::\\w)?>", DefaultRegexOptions),
(_, m) =>
{
// TODO: support formatting parameters
@@ -244,17 +244,19 @@ internal static partial class MarkdownParser
if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture,
out var offset))
{
return null;
return new UnixTimestampNode(null);
}
// Bound check
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
if (offset < TimeSpan.MinValue.TotalSeconds || offset > TimeSpan.MaxValue.TotalSeconds)
try
{
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;
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="MaterialDesignThemes" Version="4.3.0" />
<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="Stylet" Version="1.3.6" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<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>
@@ -1,5 +1,4 @@
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting;
using Tyrrrz.Settings;
namespace DiscordChatExporter.Gui.Services;
@@ -18,7 +17,7 @@ public class SettingsService : SettingsManager
public bool ShouldReuseMedia { get; set; }
public AuthToken? LastToken { get; set; }
public string? LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
@@ -25,6 +25,8 @@ public class RootViewModel : Screen
private readonly SettingsService _settingsService;
private readonly UpdateService _updateService;
private DiscordClient? _discord;
public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
public IProgressManager ProgressManager { get; } = new ProgressManager();
@@ -33,9 +35,7 @@ public class RootViewModel : Screen
public bool IsProgressIndeterminate { get; private set; }
public bool IsBotToken { get; set; }
public string? TokenValue { get; set; }
public string? Token { get; set; }
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
@@ -111,8 +111,7 @@ public class RootViewModel : Screen
if (_settingsService.LastToken is not null)
{
IsBotToken = _settingsService.LastToken.Kind == AuthTokenKind.Bot;
TokenValue = _settingsService.LastToken.Value;
Token = _settingsService.LastToken;
}
if (_settingsService.IsDarkModeEnabled)
@@ -144,7 +143,7 @@ public class RootViewModel : Screen
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
!IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PopulateGuildsAndChannels()
{
@@ -152,15 +151,10 @@ public class RootViewModel : Screen
try
{
var tokenValue = TokenValue?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(tokenValue))
var token = Token?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(token))
return;
var token = new AuthToken(
IsBotToken ? AuthTokenKind.Bot : AuthTokenKind.User,
tokenValue
);
_settingsService.LastToken = token;
var discord = new DiscordClient(token);
@@ -172,6 +166,7 @@ public class RootViewModel : Screen
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
}
_discord = discord;
GuildChannelMap = guildChannelMap;
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
}
@@ -191,21 +186,24 @@ public class RootViewModel : Screen
}
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()
{
try
{
var token = _settingsService.LastToken;
if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
var exporter = new ChannelExporter(token);
var exporter = new ChannelExporter(_discord);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0;
@@ -35,7 +35,7 @@
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="16,0,16,16"
Margin="16,8"
Text="{Binding Message}"
TextWrapping="Wrap" />
</ScrollViewer>
@@ -24,126 +24,129 @@
FontWeight="Light"
Text="Settings" />
<ScrollViewer
<Border
Grid.Row="1"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Auto-updates -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Perform automatic updates on every launch">
<TextBlock
VerticalAlignment="Center"
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">
Padding="0,8"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Auto-updates -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Perform automatic updates on every launch">
<TextBlock
Margin="10,0"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{Binding ParallelLimit}" />
<Slider
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"
Maximum="10"
Minimum="1"
Style="{DynamicResource MaterialDesignThinSlider}"
Value="{Binding ParallelLimit}" />
</StackPanel>
</DockPanel>
</StackPanel>
</ScrollViewer>
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
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 -->
<Button
+44 -66
View File
@@ -64,39 +64,28 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token type -->
<ToggleButton
<!-- Token icon -->
<materialDesign:PackIcon
Grid.Column="0"
Margin="6"
IsChecked="{Binding IsBotToken}"
Style="{DynamicResource MaterialDesignFlatActionToggleButton}"
ToolTip="Switch between user token and bot token">
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Account" />
</ToggleButton.Content>
<materialDesign:ToggleButtonAssist.OnContent>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Robot" />
</materialDesign:ToggleButtonAssist.OnContent>
</ToggleButton>
Width="24"
Height="24"
Margin="8"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Password" />
<!-- Token value -->
<TextBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="2,6,6,7"
Margin="0,6,6,8"
VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0"
FontSize="16"
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
<!-- Pull data button -->
<Button
@@ -152,12 +141,25 @@
</Style>
</Grid.Resources>
<!-- Placeholder / usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<TextBlock FontSize="18" Text="Please provide your user token to authorize" />
<TextBlock Margin="8,8,0,0" FontSize="14">
<Run Text="1. Open Discord" />
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock Margin="32,16" FontSize="14">
<Run FontSize="18" Text="Please provide authentication token to continue" />
<LineBreak />
<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 />
<Run Text="2. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
@@ -189,35 +191,20 @@
<Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" />
<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 />
<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>
<materialDesign:PackIcon
Margin="1,0,0,-3"
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
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>
<!-- 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 FontSize="16" Text="Authenticate as a bot" />
<LineBreak />
<Run Text="1. Open Discord developer portal" />
<LineBreak />
<Run Text="2. Open your application's settings" />
@@ -230,22 +217,13 @@
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<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 Margin="0,24,0,0" FontSize="14">
<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>
</ScrollViewer>
</Grid>
<!-- Guilds and channels -->
+1 -1
View File
@@ -1,5 +1,5 @@
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
it under the terms of the GNU General Public License as published by