pull/410/head^2
Tyrrrz 4 years ago
parent 0763a99765
commit 1da80956dd

@ -11,28 +11,36 @@ namespace DiscordChatExporter.Cli.Commands.Base
{ {
public abstract class ExportCommandBase : TokenCommandBase public abstract class ExportCommandBase : TokenCommandBase
{ {
[CommandOption("output", 'o', Description = "Output file or directory path.")] [CommandOption("output", 'o',
Description = "Output file or directory path.")]
public string OutputPath { get; set; } = Directory.GetCurrentDirectory(); public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Output file format.")] [CommandOption("format", 'f',
Description = "Export format.")]
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark; public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
[CommandOption("after", Description = "Limit to messages sent after this date.")] [CommandOption("after",
Description = "Only include messages sent after this date.")]
public DateTimeOffset? After { get; set; } public DateTimeOffset? After { get; set; }
[CommandOption("before", Description = "Limit to messages sent before this date.")] [CommandOption("before",
Description = "Only include messages sent before this date.")]
public DateTimeOffset? Before { get; set; } public DateTimeOffset? Before { get; set; }
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")] [CommandOption("partition", 'p',
Description = "Split output into partitions limited to this number of messages.")]
public int? PartitionLimit { get; set; } public int? PartitionLimit { get; set; }
[CommandOption("media", Description = "Download referenced media content.")] [CommandOption("media",
Description = "Download referenced media content.")]
public bool ShouldDownloadMedia { get; set; } public bool ShouldDownloadMedia { get; set; }
[CommandOption("reuse-media", Description = "If the media folder already exists, reuse media inside it to skip downloads.")] [CommandOption("reuse-media",
Description = "Reuse already existing media content to skip redundant downloads.")]
public bool ShouldReuseMedia { get; set; } public bool ShouldReuseMedia { get; set; }
[CommandOption("dateformat", Description = "Date format used in output.")] [CommandOption("dateformat",
Description = "Format used when writing dates.")]
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt"; public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient()); protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient());

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -16,21 +17,23 @@ namespace DiscordChatExporter.Cli.Commands.Base
{ {
public abstract class ExportMultipleCommandBase : ExportCommandBase public abstract class ExportMultipleCommandBase : ExportCommandBase
{ {
[CommandOption("parallel", Description = "Export this number of channels in parallel.")] [CommandOption("parallel",
Description = "Limits how many channels can be exported in parallel.")]
public int ParallelLimit { get; set; } = 1; public int ParallelLimit { get; set; } = 1;
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
// HACK: this uses a separate route from ExportCommandBase because the progress ticker is not thread-safe // This uses a different route from ExportCommandBase.ExportAsync() because it runs
// in parallel and needs another way to report progress to console.
console.Output.Write($"Exporting {channels.Count} channels... "); console.Output.Write($"Exporting {channels.Count} channels... ");
var progress = console.CreateProgressTicker(); var progress = console.CreateProgressTicker();
var operations = progress.Wrap().CreateOperations(channels.Count); var operations = progress.Wrap().CreateOperations(channels.Count);
var errors = new List<string>();
var successfulExportCount = 0; var successfulExportCount = 0;
var errors = new ConcurrentBag<string>();
await channels.Zip(operations).ParallelForEachAsync(async tuple => await channels.Zip(operations).ParallelForEachAsync(async tuple =>
{ {
var (channel, operation) = tuple; var (channel, operation) = tuple;

@ -7,15 +7,22 @@ namespace DiscordChatExporter.Cli.Commands.Base
{ {
public abstract class TokenCommandBase : ICommand public abstract class TokenCommandBase : ICommand
{ {
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN", [CommandOption("token", 't', IsRequired = true,
EnvironmentVariableName = "DISCORD_TOKEN",
Description = "Authorization token.")] Description = "Authorization token.")]
public string TokenValue { get; set; } = ""; public string TokenValue { get; set; } = "";
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT", [CommandOption("bot", 'b',
Description = "Whether this authorization token belongs to a bot.")] EnvironmentVariableName = "DISCORD_TOKEN_BOT",
Description = "Authorize as a bot.")]
public bool IsBotToken { get; set; } public bool IsBotToken { get; set; }
protected AuthToken GetAuthToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue); protected AuthToken GetAuthToken() => new AuthToken(
IsBotToken
? AuthTokenType.Bot
: AuthTokenType.User,
TokenValue
);
protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken()); protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken());

@ -10,7 +10,8 @@ namespace DiscordChatExporter.Cli.Commands
[Command("exportall", Description = "Export all accessible channels.")] [Command("exportall", Description = "Export all accessible channels.")]
public class ExportAllCommand : ExportMultipleCommandBase public class ExportAllCommand : ExportMultipleCommandBase
{ {
[CommandOption("include-dm", Description = "Whether to also export direct message channels.")] [CommandOption("include-dm",
Description = "Include direct message channels.")]
public bool IncludeDirectMessages { get; set; } = true; public bool IncludeDirectMessages { get; set; } = true;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
@ -33,4 +34,4 @@ namespace DiscordChatExporter.Cli.Commands
await ExportMultipleAsync(console, channels); await ExportMultipleAsync(console, channels);
} }
} }
} }

@ -8,9 +8,11 @@ namespace DiscordChatExporter.Cli.Commands
[Command("export", Description = "Export a channel.")] [Command("export", Description = "Export a channel.")]
public class ExportChannelCommand : ExportCommandBase public class ExportChannelCommand : ExportCommandBase
{ {
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")] [CommandOption("channel", 'c', IsRequired = true,
Description = "Channel ID.")]
public string ChannelId { get; set; } = ""; public string ChannelId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console) => await ExportAsync(console, ChannelId); public override async ValueTask ExecuteAsync(IConsole console) =>
await ExportAsync(console, ChannelId);
} }
} }

@ -9,7 +9,8 @@ namespace DiscordChatExporter.Cli.Commands
[Command("exportguild", Description = "Export all channels within specified guild.")] [Command("exportguild", Description = "Export all channels within specified guild.")]
public class ExportGuildCommand : ExportMultipleCommandBase public class ExportGuildCommand : ExportMultipleCommandBase
{ {
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")] [CommandOption("guild", 'g', IsRequired = true,
Description = "Guild ID.")]
public string GuildId { get; set; } = ""; public string GuildId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)

@ -10,7 +10,8 @@ namespace DiscordChatExporter.Cli.Commands
[Command("channels", Description = "Get the list of channels in a guild.")] [Command("channels", Description = "Get the list of channels in a guild.")]
public class GetChannelsCommand : TokenCommandBase public class GetChannelsCommand : TokenCommandBase
{ {
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")] [CommandOption("guild", 'g', IsRequired = true,
Description = "Guild ID.")]
public string GuildId { get; set; } = ""; public string GuildId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)

@ -10,7 +10,9 @@ namespace DiscordChatExporter.Cli.Commands
{ {
public ValueTask ExecuteAsync(IConsole console) public ValueTask ExecuteAsync(IConsole console)
{ {
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get user token:")); console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get user token:")
);
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
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. Navigate to the Application tab"); console.Output.WriteLine(" 3. Navigate to the Application tab");
@ -20,14 +22,18 @@ namespace DiscordChatExporter.Cli.Commands
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk."); console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
console.Output.WriteLine(); console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get bot token:")); console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get bot token:")
);
console.Output.WriteLine(" 1. Go to Discord developer portal"); console.Output.WriteLine(" 1. Go to Discord developer portal");
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Copy");
console.Output.WriteLine(); console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get guild ID or guild channel ID:")); console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get guild ID or guild channel ID:")
);
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open Settings"); console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Appearance section"); console.Output.WriteLine(" 3. Go to Appearance section");
@ -35,7 +41,9 @@ namespace DiscordChatExporter.Cli.Commands
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID"); console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
console.Output.WriteLine(); console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get direct message channel ID:")); console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get direct message channel ID:")
);
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open the desired direct message channel"); console.Output.WriteLine(" 2. Open the desired direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
@ -44,8 +52,12 @@ namespace DiscordChatExporter.Cli.Commands
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL"); console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
console.Output.WriteLine(); console.Output.WriteLine();
console.Output.WriteLine("For more information, check out the wiki:"); console.WithForegroundColor(ConsoleColor.White,
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki"); () => console.Output.WriteLine("For more information, check out the wiki:")
);
console.WithForegroundColor(ConsoleColor.Blue,
() => console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki")
);
return default; return default;
} }

@ -9,50 +9,33 @@ using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions; using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal; using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using Polly;
namespace DiscordChatExporter.Domain.Discord namespace DiscordChatExporter.Domain.Discord
{ {
public class DiscordClient public class DiscordClient
{ {
private readonly HttpClient _httpClient;
private readonly AuthToken _token; private readonly AuthToken _token;
private readonly HttpClient _httpClient = Singleton.HttpClient;
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute); private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
public DiscordClient(AuthToken token) public DiscordClient(HttpClient httpClient, AuthToken token)
{ {
_httpClient = httpClient;
_token = token; _token = token;
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
// finally wait the specified amount of time, based on how many requests have failed in a row.
_httpRequestPolicy = Policy
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
.WaitAndRetryAsync(6,
(i, result, ctx) =>
{
if (i <= 3)
return TimeSpan.FromSeconds(2 * i);
if (i <= 5)
return TimeSpan.FromSeconds(5 * i);
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
},
(response, timespan, retryCount, context) => Task.CompletedTask
);
} }
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () => public DiscordClient(AuthToken token)
{ : this(Http.Client, token) {}
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthorizationHeader(); private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
await Http.ResponsePolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthorizationHeader();
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
}); });
private async ValueTask<JsonElement> GetJsonResponseAsync(string url) private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
{ {

@ -41,7 +41,7 @@ namespace DiscordChatExporter.Domain.Exporting
{ {
"unix" => date.ToUnixTimeSeconds().ToString(), "unix" => date.ToUnixTimeSeconds().ToString(),
"unixms" => date.ToUnixTimeMilliseconds().ToString(), "unixms" => date.ToUnixTimeMilliseconds().ToString(),
var df => date.ToLocalString(df), var dateFormat => date.ToLocalString(dateFormat)
}; };
public Member? TryGetMember(string id) => public Member? TryGetMember(string id) =>
@ -77,7 +77,7 @@ namespace DiscordChatExporter.Domain.Exporting
// We want relative path so that the output files can be copied around without breaking // We want relative path so that the output files can be copied around without breaking
var relativeFilePath = Path.GetRelativePath(Request.OutputBaseDirPath, filePath); var relativeFilePath = Path.GetRelativePath(Request.OutputBaseDirPath, filePath);
// For HTML, we need to format the URL properly // HACK: for HTML, we need to format the URL properly
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight) if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
{ {
// Need to escape each path segment while keeping the directory separators intact // Need to escape each path segment while keeping the directory separators intact
@ -94,6 +94,7 @@ namespace DiscordChatExporter.Domain.Exporting
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372 // https://github.com/Tyrrrz/DiscordChatExporter/issues/372
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException) catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
{ {
// TODO: add logging so we can be more liberal with catching exceptions
// We don't want this to crash the exporting process in case of failure // We don't want this to crash the exporting process in case of failure
return url; return url;
} }

@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@ -9,87 +8,79 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Domain.Internal; using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using Polly;
using Polly.Retry;
namespace DiscordChatExporter.Domain.Exporting namespace DiscordChatExporter.Domain.Exporting
{ {
internal partial class MediaDownloader internal partial class MediaDownloader
{ {
private readonly HttpClient _httpClient = Singleton.HttpClient; private readonly HttpClient _httpClient;
private readonly string _workingDirPath; private readonly string _workingDirPath;
private readonly bool _reuseMedia; private readonly bool _reuseMedia;
private readonly AsyncRetryPolicy _httpRequestPolicy;
private readonly Dictionary<string, string> _pathMap = new Dictionary<string, string>(); // URL -> Local file path
private readonly Dictionary<string, string> _pathCache =
new Dictionary<string, string>(StringComparer.Ordinal);
public MediaDownloader(string workingDirPath, bool reuseMedia) public MediaDownloader(HttpClient httpClient, string workingDirPath, bool reuseMedia)
{ {
_httpClient = httpClient;
_workingDirPath = workingDirPath; _workingDirPath = workingDirPath;
_reuseMedia = reuseMedia; _reuseMedia = reuseMedia;
_httpRequestPolicy = Policy
.Handle<IOException>()
.WaitAndRetryAsync(8, i => TimeSpan.FromSeconds(0.5 * i));
} }
public MediaDownloader(string workingDirPath, bool reuseMedia)
: this(Http.Client, workingDirPath, reuseMedia) {}
public async ValueTask<string> DownloadAsync(string url) public async ValueTask<string> DownloadAsync(string url)
{ {
return await _httpRequestPolicy.ExecuteAsync(async () => if (_pathCache.TryGetValue(url, out var cachedFilePath))
{ return cachedFilePath;
if (_pathMap.TryGetValue(url, out var cachedFilePath))
return cachedFilePath;
var fileName = GetFileNameFromUrl(url);
var filePath = Path.Combine(_workingDirPath, fileName);
if (!_reuseMedia) var fileName = GetFileNameFromUrl(url);
{ var filePath = Path.Combine(_workingDirPath, fileName);
filePath = PathEx.MakeUniqueFilePath(filePath);
}
if (!_reuseMedia || !File.Exists(filePath)) // Reuse existing files if we're allowed to
{ if (_reuseMedia && File.Exists(filePath))
Directory.CreateDirectory(_workingDirPath); return _pathCache[url] = filePath;
await _httpClient.DownloadAsync(url, filePath);
}
return _pathMap[url] = filePath; // Download it
Directory.CreateDirectory(_workingDirPath);
await Http.ExceptionPolicy.ExecuteAsync(async () =>
{
// This catches IOExceptions which is dangerous as we're working also with files
await _httpClient.DownloadAsync(url, filePath);
}); });
return _pathCache[url] = filePath;
} }
} }
internal partial class MediaDownloader internal partial class MediaDownloader
{ {
private static int URL_HASH_LENGTH = 5; private static string GetUrlHash(string url)
private static string HashUrl(string url)
{ {
using (var md5 = MD5.Create()) using var hash = SHA256.Create();
{
var inputBytes = Encoding.UTF8.GetBytes(url);
var hashBytes = md5.ComputeHash(inputBytes);
var hashBuilder = new StringBuilder();
for (int i = 0; i < hashBytes.Length; i++)
{
hashBuilder.Append(hashBytes[i].ToString("X2"));
}
return hashBuilder.ToString().Truncate(URL_HASH_LENGTH);
}
}
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16); var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
}
private static string GetFileNameFromUrl(string url) private static string GetFileNameFromUrl(string url)
{ {
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value; var urlHash = GetUrlHash(url);
// Try to extract file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
// If it's not there, just use the URL hash as the file name
if (string.IsNullOrWhiteSpace(fileName))
return urlHash;
var fileName = !string.IsNullOrWhiteSpace(originalFileName) // Otherwise, use the original file name but inject the hash in the middle
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(42)}-({HashUrl(url)}){Path.GetExtension(originalFileName)}" var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
: GetRandomFileName(); var fileExtension = Path.GetExtension(fileName);
return PathEx.EscapePath(fileName); return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
} }
} }
} }

@ -72,7 +72,9 @@ namespace DiscordChatExporter.Domain.Exporting
internal partial class MessageExporter internal partial class MessageExporter
{ {
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex) private static string GetPartitionFilePath(
string baseFilePath,
int partitionIndex)
{ {
// First partition - don't change file name // First partition - don't change file name
if (partitionIndex <= 0) if (partitionIndex <= 0)
@ -82,16 +84,17 @@ namespace DiscordChatExporter.Domain.Exporting
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
var fileExt = Path.GetExtension(baseFilePath); var fileExt = Path.GetExtension(baseFilePath);
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}"; var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
// Generate new path
var dirPath = Path.GetDirectoryName(baseFilePath); var dirPath = Path.GetDirectoryName(baseFilePath);
if (!string.IsNullOrWhiteSpace(dirPath))
return Path.Combine(dirPath, fileName);
return fileName; return !string.IsNullOrWhiteSpace(dirPath)
? Path.Combine(dirPath, fileName)
: fileName;
} }
private static MessageWriter CreateMessageWriter(string filePath, ExportFormat format, ExportContext context) private static MessageWriter CreateMessageWriter(
string filePath,
ExportFormat format,
ExportContext context)
{ {
// Stream will be disposed by the underlying writer // Stream will be disposed by the underlying writer
var stream = File.Create(filePath); var stream = File.Create(filePath);

@ -0,0 +1,19 @@
using System.Text;
namespace DiscordChatExporter.Domain.Internal.Extensions
{
internal static class BinaryExtensions
{
public static string ToHex(this byte[] data)
{
var buffer = new StringBuilder();
foreach (var t in data)
{
buffer.Append(t.ToString("X2"));
}
return buffer.ToString();
}
}
}

@ -0,0 +1,61 @@
using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Polly;
namespace DiscordChatExporter.Domain.Internal
{
internal static class Http
{
public static HttpClient Client { get; } = new HttpClient();
public static IAsyncPolicy<HttpResponseMessage> ResponsePolicy { get; } =
Policy
.Handle<IOException>()
.Or<HttpRequestException>()
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
.WaitAndRetryAsync(8,
(i, result, ctx) =>
{
// If rate-limited, use retry-after as a guide
if (result.Result.StatusCode == HttpStatusCode.TooManyRequests)
{
// Only start respecting retry-after after a few attempts.
// The reason is that Discord often sends unreasonable (20+ minutes) retry-after
// on the very first request.
if (i > 3)
{
var retryAfterDelay = result.Result.Headers.RetryAfter.Delta;
if (retryAfterDelay != null)
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
}
}
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
},
(response, timespan, retryCount, context) => Task.CompletedTask);
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex)
{
// This is extremely frail, but there's no other way
var statusCodeRaw = Regex.Match(ex.Message, @": (\d+) \(").Groups[1].Value;
return !string.IsNullOrWhiteSpace(statusCodeRaw)
? (HttpStatusCode) int.Parse(statusCodeRaw, CultureInfo.InvariantCulture)
: (HttpStatusCode?) null;
}
public static IAsyncPolicy ExceptionPolicy { get; } =
Policy
.Handle<IOException>() // dangerous
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.TooManyRequests)
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.RequestTimeout)
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) >= HttpStatusCode.InternalServerError)
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
}
}

@ -14,27 +14,5 @@ namespace DiscordChatExporter.Domain.Internal
} }
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString(); public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
public static string MakeUniqueFilePath(string baseFilePath, int maxAttempts = int.MaxValue)
{
if (!File.Exists(baseFilePath))
return baseFilePath;
var baseDirPath = Path.GetDirectoryName(baseFilePath);
var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath);
var baseFileExtension = Path.GetExtension(baseFilePath);
for (var i = 1; i <= maxAttempts; i++)
{
var filePath = $"{baseFileNameWithoutExtension} ({i}){baseFileExtension}";
if (!string.IsNullOrWhiteSpace(baseDirPath))
filePath = Path.Combine(baseDirPath, filePath);
if (!File.Exists(filePath))
return filePath;
}
return baseFilePath;
}
} }
} }

@ -1,23 +0,0 @@
using System;
using System.Net;
using System.Net.Http;
namespace DiscordChatExporter.Domain.Internal
{
internal static class Singleton
{
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
{
var handler = new HttpClientHandler();
if (handler.SupportsAutomaticDecompression)
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
handler.UseCookies = false;
return new HttpClient(handler, true);
});
public static HttpClient HttpClient { get; } = LazyHttpClient.Value;
}
}

@ -9,7 +9,8 @@ namespace DiscordChatExporter.Domain.Utilities
{ {
public static class AsyncExtensions public static class AsyncExtensions
{ {
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable) private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(
this IAsyncEnumerable<T> asyncEnumerable)
{ {
var list = new List<T>(); var list = new List<T>();
@ -19,10 +20,14 @@ namespace DiscordChatExporter.Domain.Utilities
return list; return list;
} }
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) => public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
this IAsyncEnumerable<T> asyncEnumerable) =>
asyncEnumerable.AggregateAsync().GetAwaiter(); asyncEnumerable.AggregateAsync().GetAwaiter();
public static async ValueTask ParallelForEachAsync<T>(this IEnumerable<T> source, Func<T, Task> handleAsync, int degreeOfParallelism) public static async ValueTask ParallelForEachAsync<T>(
this IEnumerable<T> source,
Func<T, ValueTask> handleAsync,
int degreeOfParallelism)
{ {
using var semaphore = new SemaphoreSlim(degreeOfParallelism); using var semaphore = new SemaphoreSlim(degreeOfParallelism);

@ -7,13 +7,17 @@ namespace DiscordChatExporter.Gui
{ {
public partial class App public partial class App
{ {
private static readonly Assembly Assembly = typeof(App).Assembly; private static Assembly Assembly { get; } = typeof(App).Assembly;
public static string Name => Assembly.GetName().Name!; public static string Name { get; } = Assembly.GetName().Name!;
public static Version Version => Assembly.GetName().Version!; public static Version Version { get; } = Assembly.GetName().Version!;
public static string VersionString => Version.ToString(3); public static string VersionString { get; } = Version.ToString(3);
public static string GitHubProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
public static string GitHubProjectWikiUrl { get; } = GitHubProjectUrl + "/wiki";
} }
public partial class App public partial class App

@ -31,9 +31,9 @@ namespace DiscordChatExporter.Gui.Behaviors
private bool _viewHandled; private bool _viewHandled;
private bool _modelHandled; private bool _modelHandled;
public IList SelectedItems public IList? SelectedItems
{ {
get => (IList) GetValue(SelectedItemsProperty); get => (IList?) GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value); set => SetValue(SelectedItemsProperty, value);
} }

@ -17,7 +17,8 @@ namespace DiscordChatExporter.Gui
{ {
base.OnStart(); base.OnStart();
// Light theme is the default // Set default theme
// (preferred theme will be chosen later, once the settings are loaded)
App.SetLightTheme(); App.SetLightTheme();
} }

@ -18,9 +18,7 @@ namespace DiscordChatExporter.Gui.Converters
return default(string); return default(string);
} }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
{ throw new NotSupportedException();
throw new NotImplementedException();
}
} }
} }

@ -11,8 +11,7 @@ namespace DiscordChatExporter.Gui.Internal
UseShellExecute = true UseShellExecute = true
}; };
using (Process.Start(startInfo)) using (Process.Start(startInfo)) {}
{ }
} }
} }
} }

@ -16,7 +16,7 @@ namespace DiscordChatExporter.Gui.Services
public int ParallelLimit { get; set; } = 1; public int ParallelLimit { get; set; } = 1;
public bool ShouldReuseMedia { get; set; } = false; public bool ShouldReuseMedia { get; set; }
public AuthToken? LastToken { get; set; } public AuthToken? LastToken { get; set; }

@ -10,7 +10,8 @@ namespace DiscordChatExporter.Gui.Services
{ {
private readonly IUpdateManager _updateManager = new UpdateManager( private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"), new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor()); new ZipPackageExtractor()
);
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;

@ -75,10 +75,16 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia; _settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
// If single channel - prompt file path // If single channel - prompt file path
if (IsSingleChannel) if (Channels != null && IsSingleChannel)
{ {
var channel = Channels.Single(); var channel = Channels.Single();
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before); var defaultFileName = ExportRequest.GetDefaultOutputFileName(
Guild!,
channel,
SelectedFormat,
After,
Before
);
// Filter // Filter
var ext = SelectedFormat.GetFileExtension(); var ext = SelectedFormat.GetFileExtension();
@ -92,11 +98,24 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
OutputPath = _dialogManager.PromptDirectoryPath(); OutputPath = _dialogManager.PromptDirectoryPath();
} }
// If canceled - return
if (string.IsNullOrWhiteSpace(OutputPath)) if (string.IsNullOrWhiteSpace(OutputPath))
return; return;
Close(true); Close(true);
} }
} }
public static class ExportSetupViewModelExtensions
{
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
Guild guild, IReadOnlyList<Channel> channels)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
}
} }

@ -19,13 +19,10 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen) public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
{ {
// Get the view that renders this viewmodel
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
// Set up event routing that will close the view when called from viewmodel
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs) void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
{ {
// Delegate to close the dialog and unregister event handler
void OnScreenClosed(object? o, EventArgs closeArgs) void OnScreenClosed(object? o, EventArgs closeArgs)
{ {
openArgs.Session.Close(); openArgs.Session.Close();
@ -35,37 +32,31 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
dialogScreen.Closed += OnScreenClosed; dialogScreen.Closed += OnScreenClosed;
} }
// Show view
await DialogHost.Show(view, OnDialogOpened); await DialogHost.Show(view, OnDialogOpened);
// Return the result
return dialogScreen.DialogResult; return dialogScreen.DialogResult;
} }
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "") public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
{ {
// Create dialog
var dialog = new SaveFileDialog var dialog = new SaveFileDialog
{ {
Filter = filter, Filter = filter,
AddExtension = true, AddExtension = true,
FileName = defaultFilePath, FileName = defaultFilePath,
DefaultExt = Path.GetExtension(defaultFilePath) ?? "" DefaultExt = Path.GetExtension(defaultFilePath)
}; };
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.FileName : null; return dialog.ShowDialog() == true ? dialog.FileName : null;
} }
public string? PromptDirectoryPath(string defaultDirPath = "") public string? PromptDirectoryPath(string defaultDirPath = "")
{ {
// Create dialog
var dialog = new VistaFolderBrowserDialog var dialog = new VistaFolderBrowserDialog
{ {
SelectedPath = defaultDirPath SelectedPath = defaultDirPath
}; };
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.SelectedPath : null; return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
} }
} }

@ -1,19 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
public static class Extensions
{
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
Guild guild, IReadOnlyList<Channel> channels)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
}
}

@ -8,7 +8,9 @@ using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions; using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Domain.Utilities;
using DiscordChatExporter.Gui.Internal;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress; using Gress;
using MaterialDesignThemes.Wpf; using MaterialDesignThemes.Wpf;
@ -63,14 +65,21 @@ namespace DiscordChatExporter.Gui.ViewModels
// Update busy state when progress manager changes // Update busy state when progress manager changes
ProgressManager.Bind(o => o.IsActive, ProgressManager.Bind(o => o.IsActive,
(sender, args) => IsBusy = ProgressManager.IsActive); (sender, args) => IsBusy = ProgressManager.IsActive
);
ProgressManager.Bind(o => o.IsActive, ProgressManager.Bind(o => o.IsActive,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); (sender, args) => IsProgressIndeterminate =
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
);
ProgressManager.Bind(o => o.Progress, ProgressManager.Bind(o => o.Progress,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); (sender, args) => IsProgressIndeterminate =
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
);
} }
private async ValueTask HandleAutoUpdateAsync() private async ValueTask CheckForUpdatesAsync()
{ {
try try
{ {
@ -117,7 +126,7 @@ namespace DiscordChatExporter.Gui.ViewModels
App.SetLightTheme(); App.SetLightTheme();
} }
await HandleAutoUpdateAsync(); await CheckForUpdatesAsync();
} }
protected override void OnClose() protected override void OnClose()
@ -134,6 +143,8 @@ namespace DiscordChatExporter.Gui.ViewModels
await _dialogManager.ShowDialogAsync(dialog); await _dialogManager.ShowDialogAsync(dialog);
} }
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels => public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue); !IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
@ -187,8 +198,8 @@ namespace DiscordChatExporter.Gui.ViewModels
var exporter = new ChannelExporter(token); var exporter = new ChannelExporter(token);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count); var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0; var successfulExportCount = 0;
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple => await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
{ {
var (channel, operation) = tuple; var (channel, operation) = tuple;

@ -95,7 +95,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent after this date will be exported" /> ToolTip="Only include messages sent after this date" />
<DatePicker <DatePicker
Grid.Row="0" Grid.Row="0"
Grid.Column="1" Grid.Column="1"
@ -104,7 +104,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent before this date will be exported" /> ToolTip="Only include messages sent before this date" />
<materialDesign:TimePicker <materialDesign:TimePicker
Grid.Row="1" Grid.Row="1"
Grid.Column="0" Grid.Column="0"
@ -113,7 +113,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsAfterDateSet}" IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}" SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent after this time will be exported" /> ToolTip="Only include messages sent after this time" />
<materialDesign:TimePicker <materialDesign:TimePicker
Grid.Row="1" Grid.Row="1"
Grid.Column="1" Grid.Column="1"
@ -122,7 +122,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsBeforeDateSet}" IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}" SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent before this time will be exported" /> ToolTip="Only include messages sent before this time" />
</Grid> </Grid>
<!-- Partitioning --> <!-- Partitioning -->
@ -131,10 +131,10 @@
materialDesign:HintAssist.Hint="Messages per partition" materialDesign:HintAssist.Hint="Messages per partition"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimit, TargetNullValue=''}" Text="{Binding PartitionLimit, TargetNullValue=''}"
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" /> ToolTip="Split output into partitions limited to this number of messages" />
<!-- Download media --> <!-- Download media -->
<Grid Margin="16,16" ToolTip="If this is set, the export will include additional files such as user avatars, attached files, embedded images, etc"> <Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@ -143,7 +143,7 @@
<TextBlock <TextBlock
Grid.Column="0" Grid.Column="0"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="Download referenced media content" /> Text="Download media" />
<ToggleButton <ToggleButton
Grid.Column="1" Grid.Column="1"
HorizontalAlignment="Right" HorizontalAlignment="Right"
@ -168,9 +168,9 @@
Height="24" Height="24"
Margin="12" Margin="12"
Cursor="Hand" Cursor="Hand"
Loaded="AdvancedSectionToggleButton_OnLoaded" IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
Style="{DynamicResource MaterialDesignHamburgerToggleButton}" Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
ToolTip="Show advanced options" /> ToolTip="Toggle advanced options" />
<Button <Button
Grid.Column="2" Grid.Column="2"

@ -1,7 +1,4 @@
using System.Windows; namespace DiscordChatExporter.Gui.Views.Dialogs
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.Views.Dialogs
{ {
public partial class ExportSetupView public partial class ExportSetupView
{ {
@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
{ {
InitializeComponent(); InitializeComponent();
} }
private void AdvancedSectionToggleButton_OnLoaded(object sender, RoutedEventArgs e)
{
if (DataContext is ExportSetupViewModel vm)
AdvancedSectionToggleButton.IsChecked = vm.IsAdvancedSectionDisplayedByDefault;
}
} }
} }

@ -65,15 +65,15 @@
IsChecked="{Binding IsTokenPersisted}" /> IsChecked="{Binding IsTokenPersisted}" />
</DockPanel> </DockPanel>
<!-- Reuse Media --> <!-- Reuse media -->
<DockPanel <DockPanel
Background="Transparent" Background="Transparent"
LastChildFill="False" LastChildFill="False"
ToolTip="If the media folder already exists, reuse media inside it to skip downloads"> ToolTip="Reuse already existing media content to skip redundant downloads">
<TextBlock <TextBlock
Margin="16,8" Margin="16,8"
DockPanel.Dock="Left" DockPanel.Dock="Left"
Text="Reuse previously downloaded media" /> Text="Reuse downloaded media" />
<ToggleButton <ToggleButton
Margin="16,8" Margin="16,8"
DockPanel.Dock="Right" DockPanel.Dock="Right"
@ -86,7 +86,7 @@
materialDesign:HintAssist.Hint="Date format" materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}" Text="{Binding DateFormat}"
ToolTip="Format used when rendering dates (uses .NET date formatting rules)" /> ToolTip="Format used when writing dates (uses .NET date formatting rules)" />
<!-- Parallel limit --> <!-- Parallel limit -->
<StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time"> <StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time">

@ -9,14 +9,10 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
InitializeComponent(); InitializeComponent();
} }
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
{
App.SetDarkTheme(); App.SetDarkTheme();
}
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
{
App.SetLightTheme(); App.SetLightTheme();
}
} }
} }

@ -191,10 +191,11 @@
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" /> Kind="Account" />
</InlineUIContainer> </InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> <TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" /> <Run Text="For more information, check out the" />
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." /> <Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
@ -229,10 +230,11 @@
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" /> Kind="Robot" />
</InlineUIContainer> </InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> <TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" /> <Run Text="For more information, check out the" />
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." /> <Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</Grid> </Grid>

@ -1,7 +1,4 @@
using System.Windows.Navigation; namespace DiscordChatExporter.Gui.Views
using DiscordChatExporter.Gui.Internal;
namespace DiscordChatExporter.Gui.Views
{ {
public partial class RootView public partial class RootView
{ {
@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views
{ {
InitializeComponent(); InitializeComponent();
} }
private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e)
{
ProcessEx.StartShellExecute(e.Uri.AbsoluteUri);
e.Handled = true;
}
} }
} }
Loading…
Cancel
Save