mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-06-06 01:21:18 -04:00
Refactor
This commit is contained in:
parent
0763a99765
commit
1da80956dd
34 changed files with 299 additions and 261 deletions
|
@ -11,28 +11,36 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||
{
|
||||
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();
|
||||
|
||||
[CommandOption("format", 'f', Description = "Output file format.")]
|
||||
[CommandOption("format", 'f',
|
||||
Description = "Export format.")]
|
||||
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; }
|
||||
|
||||
[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; }
|
||||
|
||||
[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; }
|
||||
|
||||
[CommandOption("media", Description = "Download referenced media content.")]
|
||||
[CommandOption("media",
|
||||
Description = "Download referenced media content.")]
|
||||
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; }
|
||||
|
||||
[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";
|
||||
|
||||
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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -16,21 +17,23 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||
{
|
||||
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;
|
||||
|
||||
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... ");
|
||||
var progress = console.CreateProgressTicker();
|
||||
|
||||
var operations = progress.Wrap().CreateOperations(channels.Count);
|
||||
|
||||
var errors = new List<string>();
|
||||
|
||||
var successfulExportCount = 0;
|
||||
var errors = new ConcurrentBag<string>();
|
||||
|
||||
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||
{
|
||||
var (channel, operation) = tuple;
|
||||
|
|
|
@ -7,15 +7,22 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||
{
|
||||
public abstract class TokenCommandBase : ICommand
|
||||
{
|
||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN",
|
||||
[CommandOption("token", 't', IsRequired = true,
|
||||
EnvironmentVariableName = "DISCORD_TOKEN",
|
||||
Description = "Authorization token.")]
|
||||
public string TokenValue { get; set; } = "";
|
||||
|
||||
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT",
|
||||
Description = "Whether this authorization token belongs to a bot.")]
|
||||
[CommandOption("bot", 'b',
|
||||
EnvironmentVariableName = "DISCORD_TOKEN_BOT",
|
||||
Description = "Authorize as a bot.")]
|
||||
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());
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ namespace DiscordChatExporter.Cli.Commands
|
|||
[Command("exportall", Description = "Export all accessible channels.")]
|
||||
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 override async ValueTask ExecuteAsync(IConsole console)
|
||||
|
|
|
@ -8,9 +8,11 @@ namespace DiscordChatExporter.Cli.Commands
|
|||
[Command("export", Description = "Export a channel.")]
|
||||
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 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.")]
|
||||
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 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.")]
|
||||
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 override async ValueTask ExecuteAsync(IConsole console)
|
||||
|
|
|
@ -10,7 +10,9 @@ namespace DiscordChatExporter.Cli.Commands
|
|||
{
|
||||
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(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||
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();
|
||||
|
||||
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(" 2. Open your application's settings");
|
||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||
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(" 2. Open Settings");
|
||||
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();
|
||||
|
||||
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(" 2. Open the desired direct message channel");
|
||||
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();
|
||||
|
||||
console.Output.WriteLine("For more information, check out the wiki:");
|
||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
||||
console.WithForegroundColor(ConsoleColor.White,
|
||||
() => 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;
|
||||
}
|
||||
|
|
|
@ -9,44 +9,27 @@ using DiscordChatExporter.Domain.Discord.Models;
|
|||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Polly;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
public class DiscordClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
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);
|
||||
|
||||
public DiscordClient(AuthToken token)
|
||||
public DiscordClient(HttpClient httpClient, AuthToken token)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_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) {}
|
||||
|
||||
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();
|
||||
|
|
|
@ -41,7 +41,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||
{
|
||||
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||
var df => date.ToLocalString(df),
|
||||
var dateFormat => date.ToLocalString(dateFormat)
|
||||
};
|
||||
|
||||
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
|
||||
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)
|
||||
{
|
||||
// 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
|
||||
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
|
||||
return url;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
@ -9,87 +8,79 @@ using System.Text.RegularExpressions;
|
|||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _workingDirPath;
|
||||
|
||||
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;
|
||||
_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)
|
||||
{
|
||||
return await _httpRequestPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
if (_pathMap.TryGetValue(url, out var cachedFilePath))
|
||||
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||
return cachedFilePath;
|
||||
|
||||
var fileName = GetFileNameFromUrl(url);
|
||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||
|
||||
if (!_reuseMedia)
|
||||
{
|
||||
filePath = PathEx.MakeUniqueFilePath(filePath);
|
||||
}
|
||||
// Reuse existing files if we're allowed to
|
||||
if (_reuseMedia && File.Exists(filePath))
|
||||
return _pathCache[url] = filePath;
|
||||
|
||||
if (!_reuseMedia || !File.Exists(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 _pathMap[url] = filePath;
|
||||
});
|
||||
|
||||
return _pathCache[url] = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private static int URL_HASH_LENGTH = 5;
|
||||
private static string HashUrl(string url)
|
||||
private static string GetUrlHash(string url)
|
||||
{
|
||||
using (var md5 = MD5.Create())
|
||||
{
|
||||
var inputBytes = Encoding.UTF8.GetBytes(url);
|
||||
var hashBytes = md5.ComputeHash(inputBytes);
|
||||
using var hash = SHA256.Create();
|
||||
|
||||
var hashBuilder = new StringBuilder();
|
||||
for (int i = 0; i < hashBytes.Length; i++)
|
||||
{
|
||||
hashBuilder.Append(hashBytes[i].ToString("X2"));
|
||||
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
||||
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
||||
}
|
||||
return hashBuilder.ToString().Truncate(URL_HASH_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
{
|
||||
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
var urlHash = GetUrlHash(url);
|
||||
|
||||
var fileName = !string.IsNullOrWhiteSpace(originalFileName)
|
||||
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(42)}-({HashUrl(url)}){Path.GetExtension(originalFileName)}"
|
||||
: GetRandomFileName();
|
||||
// Try to extract file name from URL
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
return PathEx.EscapePath(fileName);
|
||||
// If it's not there, just use the URL hash as the file name
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return urlHash;
|
||||
|
||||
// Otherwise, use the original file name but inject the hash in the middle
|
||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||
var fileExtension = Path.GetExtension(fileName);
|
||||
|
||||
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,7 +72,9 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||
|
||||
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
|
||||
if (partitionIndex <= 0)
|
||||
|
@ -82,16 +84,17 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||
var fileExt = Path.GetExtension(baseFilePath);
|
||||
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
||||
|
||||
// Generate new path
|
||||
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
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
61
DiscordChatExporter.Domain/Internal/Http.cs
Normal file
61
DiscordChatExporter.Domain/Internal/Http.cs
Normal file
|
@ -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 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
|
||||
{
|
||||
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>();
|
||||
|
||||
|
@ -19,10 +20,14 @@ namespace DiscordChatExporter.Domain.Utilities
|
|||
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();
|
||||
|
||||
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);
|
||||
|
||||
|
|
|
@ -7,13 +7,17 @@ namespace DiscordChatExporter.Gui
|
|||
{
|
||||
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
|
||||
|
|
|
@ -31,9 +31,9 @@ namespace DiscordChatExporter.Gui.Behaviors
|
|||
private bool _viewHandled;
|
||||
private bool _modelHandled;
|
||||
|
||||
public IList SelectedItems
|
||||
public IList? SelectedItems
|
||||
{
|
||||
get => (IList) GetValue(SelectedItemsProperty);
|
||||
get => (IList?) GetValue(SelectedItemsProperty);
|
||||
set => SetValue(SelectedItemsProperty, value);
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,8 @@ namespace DiscordChatExporter.Gui
|
|||
{
|
||||
base.OnStart();
|
||||
|
||||
// Light theme is the default
|
||||
// Set default theme
|
||||
// (preferred theme will be chosen later, once the settings are loaded)
|
||||
App.SetLightTheme();
|
||||
}
|
||||
|
||||
|
|
|
@ -18,9 +18,7 @@ namespace DiscordChatExporter.Gui.Converters
|
|||
return default(string);
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
|
@ -11,8 +11,7 @@ namespace DiscordChatExporter.Gui.Internal
|
|||
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 bool ShouldReuseMedia { get; set; } = false;
|
||||
public bool ShouldReuseMedia { get; set; }
|
||||
|
||||
public AuthToken? LastToken { get; set; }
|
||||
|
||||
|
|
|
@ -10,7 +10,8 @@ namespace DiscordChatExporter.Gui.Services
|
|||
{
|
||||
private readonly IUpdateManager _updateManager = new UpdateManager(
|
||||
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||
new ZipPackageExtractor());
|
||||
new ZipPackageExtractor()
|
||||
);
|
||||
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
|
|
|
@ -75,10 +75,16 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||
|
||||
// If single channel - prompt file path
|
||||
if (IsSingleChannel)
|
||||
if (Channels != null && IsSingleChannel)
|
||||
{
|
||||
var channel = Channels.Single();
|
||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before);
|
||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
||||
Guild!,
|
||||
channel,
|
||||
SelectedFormat,
|
||||
After,
|
||||
Before
|
||||
);
|
||||
|
||||
// Filter
|
||||
var ext = SelectedFormat.GetFileExtension();
|
||||
|
@ -92,11 +98,24 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||
OutputPath = _dialogManager.PromptDirectoryPath();
|
||||
}
|
||||
|
||||
// If canceled - return
|
||||
if (string.IsNullOrWhiteSpace(OutputPath))
|
||||
return;
|
||||
|
||||
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)
|
||||
{
|
||||
// Get the view that renders this viewmodel
|
||||
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
||||
|
||||
// Set up event routing that will close the view when called from viewmodel
|
||||
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
|
||||
{
|
||||
// Delegate to close the dialog and unregister event handler
|
||||
void OnScreenClosed(object? o, EventArgs closeArgs)
|
||||
{
|
||||
openArgs.Session.Close();
|
||||
|
@ -35,37 +32,31 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
|||
dialogScreen.Closed += OnScreenClosed;
|
||||
}
|
||||
|
||||
// Show view
|
||||
await DialogHost.Show(view, OnDialogOpened);
|
||||
|
||||
// Return the result
|
||||
return dialogScreen.DialogResult;
|
||||
}
|
||||
|
||||
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
||||
{
|
||||
// Create dialog
|
||||
var dialog = new SaveFileDialog
|
||||
{
|
||||
Filter = filter,
|
||||
AddExtension = true,
|
||||
FileName = defaultFilePath,
|
||||
DefaultExt = Path.GetExtension(defaultFilePath) ?? ""
|
||||
DefaultExt = Path.GetExtension(defaultFilePath)
|
||||
};
|
||||
|
||||
// Show dialog and return result
|
||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||
}
|
||||
|
||||
public string? PromptDirectoryPath(string defaultDirPath = "")
|
||||
{
|
||||
// Create dialog
|
||||
var dialog = new VistaFolderBrowserDialog
|
||||
{
|
||||
SelectedPath = defaultDirPath
|
||||
};
|
||||
|
||||
// Show dialog and return result
|
||||
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.Exporting;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Gui.Internal;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||
using Gress;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
|
@ -63,14 +65,21 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||
|
||||
// Update busy state when progress manager changes
|
||||
ProgressManager.Bind(o => o.IsActive,
|
||||
(sender, args) => IsBusy = ProgressManager.IsActive);
|
||||
(sender, args) => IsBusy = ProgressManager.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,
|
||||
(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
|
||||
{
|
||||
|
@ -117,7 +126,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||
App.SetLightTheme();
|
||||
}
|
||||
|
||||
await HandleAutoUpdateAsync();
|
||||
await CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
protected override void OnClose()
|
||||
|
@ -134,6 +143,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||
await _dialogManager.ShowDialogAsync(dialog);
|
||||
}
|
||||
|
||||
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
|
||||
|
||||
public bool CanPopulateGuildsAndChannels =>
|
||||
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
|
||||
|
||||
|
@ -187,8 +198,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||
var exporter = new ChannelExporter(token);
|
||||
|
||||
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
|
||||
|
||||
var successfulExportCount = 0;
|
||||
|
||||
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||
{
|
||||
var (channel, operation) = tuple;
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateEnd="{Binding BeforeDate, 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
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
|
@ -104,7 +104,7 @@
|
|||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateStart="{Binding AfterDate, 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
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
|
@ -113,7 +113,7 @@
|
|||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsEnabled="{Binding IsAfterDateSet}"
|
||||
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
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
|
@ -122,7 +122,7 @@
|
|||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsEnabled="{Binding IsBeforeDateSet}"
|
||||
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>
|
||||
|
||||
<!-- Partitioning -->
|
||||
|
@ -131,10 +131,10 @@
|
|||
materialDesign:HintAssist.Hint="Messages per partition"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
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 -->
|
||||
<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>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
|
@ -143,7 +143,7 @@
|
|||
<TextBlock
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="Download referenced media content" />
|
||||
Text="Download media" />
|
||||
<ToggleButton
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
|
@ -168,9 +168,9 @@
|
|||
Height="24"
|
||||
Margin="12"
|
||||
Cursor="Hand"
|
||||
Loaded="AdvancedSectionToggleButton_OnLoaded"
|
||||
IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
|
||||
Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
|
||||
ToolTip="Show advanced options" />
|
||||
ToolTip="Toggle advanced options" />
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
using System.Windows;
|
||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||
{
|
||||
public partial class ExportSetupView
|
||||
{
|
||||
|
@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
|
|||
{
|
||||
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}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Reuse Media -->
|
||||
<!-- Reuse media -->
|
||||
<DockPanel
|
||||
Background="Transparent"
|
||||
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
|
||||
Margin="16,8"
|
||||
DockPanel.Dock="Left"
|
||||
Text="Reuse previously downloaded media" />
|
||||
Text="Reuse downloaded media" />
|
||||
<ToggleButton
|
||||
Margin="16,8"
|
||||
DockPanel.Dock="Right"
|
||||
|
@ -86,7 +86,7 @@
|
|||
materialDesign:HintAssist.Hint="Date format"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
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 -->
|
||||
<StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time">
|
||||
|
|
|
@ -9,14 +9,10 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
|
|||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
|
||||
App.SetDarkTheme();
|
||||
}
|
||||
|
||||
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e)
|
||||
{
|
||||
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
|
||||
App.SetLightTheme();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -191,10 +191,11 @@
|
|||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account" />
|
||||
</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 NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." />
|
||||
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
|
@ -229,10 +230,11 @@
|
|||
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 NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." />
|
||||
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
using System.Windows.Navigation;
|
||||
using DiscordChatExporter.Gui.Internal;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
namespace DiscordChatExporter.Gui.Views
|
||||
{
|
||||
public partial class RootView
|
||||
{
|
||||
|
@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views
|
|||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e)
|
||||
{
|
||||
ProcessEx.StartShellExecute(e.Uri.AbsoluteUri);
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue