mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 22:54:38 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5038b8541e | |||
| 1da80956dd | |||
| 0763a99765 | |||
| eda5b21684 | |||
| 2c7b054c24 | |||
| 520e023aff | |||
| 949c9d3f1e | |||
| fa825fce47 | |||
| 09acfcff59 | |||
| ddb4856516 | |||
| c4007d4a80 | |||
| 2a432c0ed2 | |||
| c5eeb5f464 | |||
| a6bf546e79 | |||
| 239c47c06e | |||
| 19f678ca01 | |||
| 08442f21b7 |
@@ -11,10 +11,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2.3.3
|
||||||
|
|
||||||
- name: Install .NET Core
|
- name: Install .NET Core
|
||||||
uses: actions/setup-dotnet@v1.4.0
|
uses: actions/setup-dotnet@v1.7.2
|
||||||
with:
|
with:
|
||||||
dotnet-version: 3.1.100
|
dotnet-version: 3.1.100
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2.3.3
|
||||||
|
|
||||||
- name: Install .NET Core
|
- name: Install .NET Core
|
||||||
uses: actions/setup-dotnet@v1.4.0
|
uses: actions/setup-dotnet@v1.7.2
|
||||||
with:
|
with:
|
||||||
dotnet-version: 3.1.100
|
dotnet-version: 3.1.100
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
### v2.24 (24-Oct-2020)
|
||||||
|
|
||||||
|
- [CLI] Updated to [CliFx v1.5](https://github.com/Tyrrrz/CliFx), which brings some UX improvements.
|
||||||
|
- [GUI] Added dark mode, which can be enabled in settings. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- [GUI] Improved the UX in a few minor places.
|
||||||
|
- [JSON] Added `embed.color` property to the output. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Added additional date formats: `unix` to write dates as a unix timestamp and `unixms` for the milliseconds version. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Added retry policy for downloading media content. This is mostly a workaround for transient failures that sometimes may occur when downloading assets referenced from embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Added an option to reuse already downloaded media to avoid redundant network requests. It can be enabled in settings in GUI, or by passing `--reuse-media` in CLI. To facilitate this feature, the file name strategy has been changed as well -- now instead of relying on the original file name and adding `(n)` to the end in case of duplicates, a URL hash is injected in every file name. This also means that when the "reuse media" option is disabled, existing files inside the media directory may be overwritten. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
|
||||||
### v2.23 (14-Sep-2020)
|
### v2.23 (14-Sep-2020)
|
||||||
|
|
||||||
- [CLI] Added a command to export all channels across all servers. Use `exportall` to do it. (Thanks [@wyattscarpenter](https://github.com/wyattscarpenter))
|
- [CLI] Added a command to export all channels across all servers. Use `exportall` to do it. (Thanks [@wyattscarpenter](https://github.com/wyattscarpenter))
|
||||||
|
|||||||
@@ -11,25 +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("dateformat", Description = "Date format used in output.")]
|
[CommandOption("reuse-media",
|
||||||
|
Description = "Reuse already existing media content to skip redundant downloads.")]
|
||||||
|
public bool ShouldReuseMedia { get; set; }
|
||||||
|
|
||||||
|
[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());
|
||||||
@@ -48,6 +59,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
ShouldDownloadMedia,
|
ShouldDownloadMedia,
|
||||||
|
ShouldReuseMedia,
|
||||||
DateFormat
|
DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -48,6 +51,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
ShouldDownloadMedia,
|
ShouldDownloadMedia,
|
||||||
|
ShouldReuseMedia,
|
||||||
DateFormat
|
DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="1.3.2" />
|
<PackageReference Include="CliFx" Version="1.5.0" />
|
||||||
<PackageReference Include="Gress" Version="1.2.0" />
|
<PackageReference Include="Gress" Version="1.2.0" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
|
||||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
|
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,10 +34,15 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
Channels = channels;
|
Channels = channels;
|
||||||
Roles = roles;
|
Roles = roles;
|
||||||
|
|
||||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath);
|
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string FormatDate(DateTimeOffset date) => date.ToLocalString(Request.DateFormat);
|
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
||||||
|
{
|
||||||
|
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||||
|
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||||
|
var dateFormat => date.ToLocalString(dateFormat)
|
||||||
|
};
|
||||||
|
|
||||||
public Member? TryGetMember(string id) =>
|
public Member? TryGetMember(string id) =>
|
||||||
Members.FirstOrDefault(m => m.Id == id);
|
Members.FirstOrDefault(m => m.Id == id);
|
||||||
@@ -72,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
|
||||||
@@ -89,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
public bool ShouldDownloadMedia { get; }
|
public bool ShouldDownloadMedia { get; }
|
||||||
|
|
||||||
|
public bool ShouldReuseMedia { get; }
|
||||||
|
|
||||||
public string DateFormat { get; }
|
public string DateFormat { get; }
|
||||||
|
|
||||||
public ExportRequest(
|
public ExportRequest(
|
||||||
@@ -41,6 +43,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
DateTimeOffset? before,
|
DateTimeOffset? before,
|
||||||
int? partitionLimit,
|
int? partitionLimit,
|
||||||
bool shouldDownloadMedia,
|
bool shouldDownloadMedia,
|
||||||
|
bool shouldReuseMedia,
|
||||||
string dateFormat)
|
string dateFormat)
|
||||||
{
|
{
|
||||||
Guild = guild;
|
Guild = guild;
|
||||||
@@ -51,6 +54,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
Before = before;
|
Before = before;
|
||||||
PartitionLimit = partitionLimit;
|
PartitionLimit = partitionLimit;
|
||||||
ShouldDownloadMedia = shouldDownloadMedia;
|
ShouldDownloadMedia = shouldDownloadMedia;
|
||||||
|
ShouldReuseMedia = shouldReuseMedia;
|
||||||
DateFormat = dateFormat;
|
DateFormat = dateFormat;
|
||||||
|
|
||||||
OutputBaseFilePath = GetOutputBaseFilePath(
|
OutputBaseFilePath = GetOutputBaseFilePath(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal;
|
||||||
@@ -11,45 +13,74 @@ 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 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)
|
public MediaDownloader(HttpClient httpClient, string workingDirPath, bool reuseMedia)
|
||||||
{
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
_workingDirPath = workingDirPath;
|
_workingDirPath = workingDirPath;
|
||||||
|
_reuseMedia = reuseMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
{
|
{
|
||||||
if (_pathMap.TryGetValue(url, out var cachedFilePath))
|
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||||
return cachedFilePath;
|
return cachedFilePath;
|
||||||
|
|
||||||
var fileName = GetFileNameFromUrl(url);
|
var fileName = GetFileNameFromUrl(url);
|
||||||
var filePath = PathEx.MakeUniqueFilePath(Path.Combine(_workingDirPath, fileName));
|
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||||
|
|
||||||
|
// Reuse existing files if we're allowed to
|
||||||
|
if (_reuseMedia && File.Exists(filePath))
|
||||||
|
return _pathCache[url] = filePath;
|
||||||
|
|
||||||
|
// Download it
|
||||||
Directory.CreateDirectory(_workingDirPath);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
await _httpClient.DownloadAsync(url, filePath);
|
return _pathCache[url] = filePath;
|
||||||
|
|
||||||
return _pathMap[url] = filePath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MediaDownloader
|
internal partial class MediaDownloader
|
||||||
{
|
{
|
||||||
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
|
private static string GetUrlHash(string url)
|
||||||
|
{
|
||||||
|
using var hash = SHA256.Create();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
var fileName = !string.IsNullOrWhiteSpace(originalFileName)
|
// Try to extract file name from URL
|
||||||
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(50)}{Path.GetExtension(originalFileName)}"
|
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||||
: GetRandomFileName();
|
|
||||||
|
|
||||||
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
|
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);
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteString("timestamp", embed.Timestamp);
|
_writer.WriteString("timestamp", embed.Timestamp);
|
||||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
||||||
|
|
||||||
|
if (embed.Color != null)
|
||||||
|
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||||
|
|
||||||
if (embed.Author != null)
|
if (embed.Author != null)
|
||||||
await WriteEmbedAuthorAsync(embed.Author);
|
await WriteEmbedAuthorAsync(embed.Author);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,5 +9,7 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
|
|||||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||||
|
|
||||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||||
|
|
||||||
|
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -14,69 +14,15 @@
|
|||||||
|
|
||||||
<!-- Merged dictionaries -->
|
<!-- Merged dictionaries -->
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
|
<!-- Colors are irrelevant because they are overriden in runtime -->
|
||||||
|
<materialDesign:BundledTheme
|
||||||
|
BaseTheme="Light"
|
||||||
|
PrimaryColor="Blue"
|
||||||
|
SecondaryColor="Blue" />
|
||||||
|
|
||||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
<!-- Colors -->
|
|
||||||
<Color x:Key="PrimaryColor">#343838</Color>
|
|
||||||
<Color x:Key="PrimaryLightColor">#5E6262</Color>
|
|
||||||
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
|
|
||||||
<Color x:Key="AccentColor">#F9A825</Color>
|
|
||||||
<Color x:Key="AccentDarkColor">#C17900</Color>
|
|
||||||
<Color x:Key="TextColor">#000000</Color>
|
|
||||||
<Color x:Key="InverseTextColor">#FFFFFF</Color>
|
|
||||||
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
|
|
||||||
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
|
|
||||||
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="PrimaryTextBrush"
|
|
||||||
Opacity="0.87"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="SecondaryTextBrush"
|
|
||||||
Opacity="0.64"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="DimTextBrush"
|
|
||||||
Opacity="0.45"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="PrimaryInverseTextBrush"
|
|
||||||
Opacity="1"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="SecondaryInverseTextBrush"
|
|
||||||
Opacity="0.7"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="DimInverseTextBrush"
|
|
||||||
Opacity="0.52"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="AccentTextBrush"
|
|
||||||
Opacity="1"
|
|
||||||
Color="{DynamicResource AccentColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="AccentDarkTextBrush"
|
|
||||||
Opacity="1"
|
|
||||||
Color="{DynamicResource AccentDarkColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="DividerBrush"
|
|
||||||
Opacity="0.12"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="InverseDividerBrush"
|
|
||||||
Opacity="0.12"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
|
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
|
||||||
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
|
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
|
||||||
@@ -84,7 +30,7 @@
|
|||||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||||
<Setter Property="TextElement.FontSize" Value="13" />
|
<Setter Property="TextElement.FontSize" Value="13" />
|
||||||
<Setter Property="TextElement.FontWeight" Value="Regular" />
|
<Setter Property="TextElement.FontWeight" Value="Regular" />
|
||||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource SecondaryTextBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
|
||||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
||||||
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
|
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
|
||||||
<Setter Property="UseLayoutRounding" Value="True" />
|
<Setter Property="UseLayoutRounding" Value="True" />
|
||||||
@@ -94,27 +40,20 @@
|
|||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
||||||
<Setter Property="Height" Value="2" />
|
<Setter Property="Height" Value="2" />
|
||||||
<Setter Property="Maximum" Value="1" />
|
<Setter Property="Maximum" Value="1" />
|
||||||
<Setter Property="Minimum" Value="0" />
|
<Setter Property="Minimum" Value="0" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
|
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
|
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}">
|
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}">
|
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style
|
<Style
|
||||||
x:Key="MaterialDesignFlatDarkButton"
|
x:Key="MaterialDesignFlatDarkButton"
|
||||||
|
|||||||
@@ -1,16 +1,49 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using DiscordChatExporter.Gui.Internal;
|
||||||
|
using MaterialDesignThemes.Wpf;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui
|
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
|
||||||
|
{
|
||||||
|
private static Theme LightTheme { get; } = Theme.Create(
|
||||||
|
new MaterialDesignLightTheme(),
|
||||||
|
MediaColor.FromHex("#343838"),
|
||||||
|
MediaColor.FromHex("#F9A825")
|
||||||
|
);
|
||||||
|
|
||||||
|
private static Theme DarkTheme { get; } = Theme.Create(
|
||||||
|
new MaterialDesignDarkTheme(),
|
||||||
|
MediaColor.FromHex("#E8E8E8"),
|
||||||
|
MediaColor.FromHex("#F9A825")
|
||||||
|
);
|
||||||
|
|
||||||
|
public static void SetLightTheme()
|
||||||
|
{
|
||||||
|
var paletteHelper = new PaletteHelper();
|
||||||
|
paletteHelper.SetTheme(LightTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetDarkTheme()
|
||||||
|
{
|
||||||
|
var paletteHelper = new PaletteHelper();
|
||||||
|
paletteHelper.SetTheme(DarkTheme);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ namespace DiscordChatExporter.Gui
|
|||||||
{
|
{
|
||||||
public class Bootstrapper : Bootstrapper<RootViewModel>
|
public class Bootstrapper : Bootstrapper<RootViewModel>
|
||||||
{
|
{
|
||||||
|
protected override void OnStart()
|
||||||
|
{
|
||||||
|
base.OnStart();
|
||||||
|
|
||||||
|
// Set default theme
|
||||||
|
// (preferred theme will be chosen later, once the settings are loaded)
|
||||||
|
App.SetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void ConfigureIoC(IStyletIoCBuilder builder)
|
protected override void ConfigureIoC(IStyletIoCBuilder builder)
|
||||||
{
|
{
|
||||||
base.ConfigureIoC(builder);
|
base.ConfigureIoC(builder);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
||||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
||||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
||||||
<PackageReference Include="Onova" Version="2.6.1" />
|
<PackageReference Include="Onova" Version="2.6.2" />
|
||||||
<PackageReference Include="Stylet" Version="1.3.4" />
|
<PackageReference Include="Stylet" Version="1.3.4" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||||
<PackageReference Include="PropertyChanged.Fody" Version="3.2.8" PrivateAssets="all" />
|
<PackageReference Include="PropertyChanged.Fody" Version="3.2.10" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
|
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
|
||||||
</xs:annotation>
|
</xs:annotation>
|
||||||
</xs:attribute>
|
</xs:attribute>
|
||||||
|
<xs:attribute name="TriggerDependentProperties" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Used to control if the Dependent properties feature is enabled.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="EnableIsChangedProperty" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Used to control if the IsChanged property feature is enabled.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
<xs:attribute name="EventInvokerNames" type="xs:string">
|
<xs:attribute name="EventInvokerNames" type="xs:string">
|
||||||
<xs:annotation>
|
<xs:annotation>
|
||||||
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
|
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Internal
|
||||||
|
{
|
||||||
|
internal static class MediaColor
|
||||||
|
{
|
||||||
|
public static Color FromHex(string hex) => (Color) ColorConverter.ConvertFromString(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,7 @@ namespace DiscordChatExporter.Gui.Internal
|
|||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
};
|
};
|
||||||
|
|
||||||
using (Process.Start(startInfo))
|
using (Process.Start(startInfo)) {}
|
||||||
{ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,18 @@ namespace DiscordChatExporter.Gui.Services
|
|||||||
{
|
{
|
||||||
public class SettingsService : SettingsManager
|
public class SettingsService : SettingsManager
|
||||||
{
|
{
|
||||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
|
||||||
|
|
||||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public bool IsDarkModeEnabled { get; set; }
|
||||||
|
|
||||||
public bool IsTokenPersisted { get; set; } = true;
|
public bool IsTokenPersisted { get; set; } = true;
|
||||||
|
|
||||||
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
|
||||||
public int ParallelLimit { get; set; } = 1;
|
public int ParallelLimit { get; set; } = 1;
|
||||||
|
|
||||||
|
public bool ShouldReuseMedia { get; set; }
|
||||||
|
|
||||||
public AuthToken? LastToken { get; set; }
|
public AuthToken? LastToken { get; set; }
|
||||||
|
|
||||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,29 +8,41 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
{
|
{
|
||||||
private readonly SettingsService _settingsService;
|
private readonly SettingsService _settingsService;
|
||||||
|
|
||||||
public string DateFormat
|
|
||||||
{
|
|
||||||
get => _settingsService.DateFormat;
|
|
||||||
set => _settingsService.DateFormat = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsAutoUpdateEnabled
|
public bool IsAutoUpdateEnabled
|
||||||
{
|
{
|
||||||
get => _settingsService.IsAutoUpdateEnabled;
|
get => _settingsService.IsAutoUpdateEnabled;
|
||||||
set => _settingsService.IsAutoUpdateEnabled = value;
|
set => _settingsService.IsAutoUpdateEnabled = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsDarkModeEnabled
|
||||||
|
{
|
||||||
|
get => _settingsService.IsDarkModeEnabled;
|
||||||
|
set => _settingsService.IsDarkModeEnabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsTokenPersisted
|
public bool IsTokenPersisted
|
||||||
{
|
{
|
||||||
get => _settingsService.IsTokenPersisted;
|
get => _settingsService.IsTokenPersisted;
|
||||||
set => _settingsService.IsTokenPersisted = value;
|
set => _settingsService.IsTokenPersisted = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string DateFormat
|
||||||
|
{
|
||||||
|
get => _settingsService.DateFormat;
|
||||||
|
set => _settingsService.DateFormat = value;
|
||||||
|
}
|
||||||
|
|
||||||
public int ParallelLimit
|
public int ParallelLimit
|
||||||
{
|
{
|
||||||
get => _settingsService.ParallelLimit;
|
get => _settingsService.ParallelLimit;
|
||||||
set => _settingsService.ParallelLimit = value.Clamp(1, 10);
|
set => _settingsService.ParallelLimit = value.Clamp(1, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool ShouldReuseMedia
|
||||||
|
{
|
||||||
|
get => _settingsService.ShouldReuseMedia;
|
||||||
|
set => _settingsService.ShouldReuseMedia = value;
|
||||||
|
}
|
||||||
|
|
||||||
public SettingsViewModel(SettingsService settingsService)
|
public SettingsViewModel(SettingsService settingsService)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
@@ -108,7 +117,16 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
TokenValue = _settingsService.LastToken.Value;
|
TokenValue = _settingsService.LastToken.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await HandleAutoUpdateAsync();
|
if (_settingsService.IsDarkModeEnabled)
|
||||||
|
{
|
||||||
|
App.SetDarkTheme();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
App.SetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
await CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClose()
|
protected override void OnClose()
|
||||||
@@ -125,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);
|
||||||
|
|
||||||
@@ -178,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;
|
||||||
@@ -195,6 +215,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
dialog.Before,
|
dialog.Before,
|
||||||
dialog.PartitionLimit,
|
dialog.PartitionLimit,
|
||||||
dialog.ShouldDownloadMedia,
|
dialog.ShouldDownloadMedia,
|
||||||
|
_settingsService.ShouldReuseMedia,
|
||||||
_settingsService.DateFormat
|
_settingsService.DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,13 @@
|
|||||||
Margin="8,0,0,0"
|
Margin="8,0,0,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="19"
|
FontSize="19"
|
||||||
|
FontWeight="Light"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
|
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
|
||||||
<Run Text="/" />
|
<Run Text="/" />
|
||||||
<Run
|
<Run
|
||||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
FontWeight="SemiBold"
|
||||||
Text="{Binding Channels[0].Name, Mode=OneWay}"
|
Text="{Binding Channels[0].Name, Mode=OneWay}"
|
||||||
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
|
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
@@ -94,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"
|
||||||
@@ -103,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"
|
||||||
@@ -112,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"
|
||||||
@@ -121,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 -->
|
||||||
@@ -130,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" />
|
||||||
@@ -142,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"
|
||||||
@@ -163,18 +164,13 @@
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
x:Name="AdvancedSectionToggleButton"
|
x:Name="AdvancedSectionToggleButton"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="8"
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="12"
|
||||||
Cursor="Hand"
|
Cursor="Hand"
|
||||||
Loaded="AdvancedSectionToggleButton_OnLoaded"
|
IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
|
||||||
Style="{DynamicResource MaterialDesignFlatToggleButton}"
|
Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
|
||||||
ToolTip="Show advanced options">
|
ToolTip="Toggle advanced options" />
|
||||||
<ToggleButton.Content>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="24"
|
|
||||||
Height="24"
|
|
||||||
Kind="Menu" />
|
|
||||||
</ToggleButton.Content>
|
|
||||||
</ToggleButton>
|
|
||||||
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:s="https://github.com/canton7/Stylet"
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
Width="300"
|
Width="310"
|
||||||
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
|
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
|
||||||
Style="{DynamicResource MaterialDesignRoot}"
|
Style="{DynamicResource MaterialDesignRoot}"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
@@ -15,17 +15,8 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
Margin="16"
|
Margin="16"
|
||||||
FontSize="17"
|
FontSize="17"
|
||||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
|
||||||
Text="Settings" />
|
Text="Settings" />
|
||||||
|
|
||||||
<!-- Date format -->
|
|
||||||
<TextBox
|
|
||||||
Margin="16,8"
|
|
||||||
materialDesign:HintAssist.Hint="Date format"
|
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
|
||||||
Text="{Binding DateFormat}"
|
|
||||||
ToolTip="Format used when rendering dates (refer to .NET date format)" />
|
|
||||||
|
|
||||||
<!-- Auto-updates -->
|
<!-- Auto-updates -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
@@ -41,6 +32,24 @@
|
|||||||
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Dark mode -->
|
||||||
|
<DockPanel
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Use darker colors in the UI">
|
||||||
|
<TextBlock
|
||||||
|
Margin="16,8"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Dark mode" />
|
||||||
|
<ToggleButton
|
||||||
|
x:Name="DarkModeToggleButton"
|
||||||
|
Margin="16,8"
|
||||||
|
Checked="DarkModeToggleButton_Checked"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding IsDarkModeEnabled}"
|
||||||
|
Unchecked="DarkModeToggleButton_Unchecked" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Persist token -->
|
<!-- Persist token -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
@@ -56,14 +65,37 @@
|
|||||||
IsChecked="{Binding IsTokenPersisted}" />
|
IsChecked="{Binding IsTokenPersisted}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Reuse media -->
|
||||||
|
<DockPanel
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Reuse already existing media content to skip redundant downloads">
|
||||||
|
<TextBlock
|
||||||
|
Margin="16,8"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Reuse downloaded media" />
|
||||||
|
<ToggleButton
|
||||||
|
Margin="16,8"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding ShouldReuseMedia}" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Date format -->
|
||||||
|
<TextBox
|
||||||
|
Margin="16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Date format"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
Text="{Binding DateFormat}"
|
||||||
|
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">
|
||||||
<TextBlock Margin="16,8">
|
<TextBlock Margin="16,8,16,4">
|
||||||
<Run Text="Parallel limit:" />
|
<Run Text="Parallel limit:" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding ParallelLimit, Mode=OneWay}" />
|
<Run Text="{Binding ParallelLimit, Mode=OneWay}" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<Slider
|
<Slider
|
||||||
Margin="16,8"
|
Margin="16,4,16,8"
|
||||||
IsSnapToTickEnabled="True"
|
IsSnapToTickEnabled="True"
|
||||||
Maximum="10"
|
Maximum="10"
|
||||||
Minimum="1"
|
Minimum="1"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||||
{
|
{
|
||||||
public partial class SettingsView
|
public partial class SettingsView
|
||||||
{
|
{
|
||||||
@@ -6,5 +8,11 @@
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
|
||||||
|
App.SetDarkTheme();
|
||||||
|
|
||||||
|
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
|
||||||
|
App.SetLightTheme();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,10 +46,7 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<Grid
|
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
|
||||||
Grid.Row="0"
|
|
||||||
Background="{DynamicResource PrimaryHueMidBrush}"
|
|
||||||
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
@@ -124,8 +121,12 @@
|
|||||||
Margin="6"
|
Margin="6"
|
||||||
Padding="4"
|
Padding="4"
|
||||||
Command="{s:Action ShowSettings}"
|
Command="{s:Action ShowSettings}"
|
||||||
Style="{DynamicResource MaterialDesignFlatDarkButton}"
|
Foreground="{DynamicResource MaterialDesignDarkForeground}"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||||
ToolTip="Settings">
|
ToolTip="Settings">
|
||||||
|
<Button.Resources>
|
||||||
|
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
|
||||||
|
</Button.Resources>
|
||||||
<materialDesign:PackIcon
|
<materialDesign:PackIcon
|
||||||
Width="24"
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
@@ -136,12 +137,20 @@
|
|||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Background="{DynamicResource PrimaryHueMidBrush}"
|
Background="{DynamicResource MaterialDesignDarkBackground}"
|
||||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
|
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<Grid Grid.Row="2" IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
<Grid
|
||||||
|
Grid.Row="2"
|
||||||
|
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||||
|
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
||||||
|
<Grid.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
</Style>
|
||||||
|
</Grid.Resources>
|
||||||
<!-- Placeholder / usage instructions -->
|
<!-- Placeholder / usage instructions -->
|
||||||
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
||||||
<!-- For user token -->
|
<!-- For user token -->
|
||||||
@@ -151,25 +160,25 @@
|
|||||||
<Run Text="1. Open Discord" />
|
<Run Text="1. Open Discord" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="2. Press" />
|
<Run Text="2. Press" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
|
||||||
<Run Text="to show developer tools" />
|
<Run Text="to show developer tools" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="3. Navigate to the" />
|
<Run Text="3. Navigate to the" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
<Run FontWeight="SemiBold" Text="Application" />
|
||||||
<Run Text="tab" />
|
<Run Text="tab" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="4. Select" />
|
<Run Text="4. Select" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
|
<Run FontWeight="SemiBold" Text="Local Storage" />
|
||||||
<Run Text=">" />
|
<Run Text=">" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discord.com" />
|
<Run FontWeight="SemiBold" Text="https://discord.com" />
|
||||||
<Run Text="on the left" />
|
<Run Text="on the left" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="5. Press" />
|
<Run Text="5. Press" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
|
<Run FontWeight="SemiBold" Text="Ctrl+R" />
|
||||||
<Run Text="to reload" />
|
<Run Text="to reload" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="6. Find" />
|
<Run Text="6. Find" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
|
<Run FontWeight="SemiBold" Text="token" />
|
||||||
<Run Text="at the bottom and copy the value" />
|
<Run Text="at the bottom and copy the value" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
<TextBlock Margin="0,24,0,0" FontSize="14">
|
||||||
@@ -177,41 +186,55 @@
|
|||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="To authorize using bot token instead, click" />
|
<Run Text="To authorize using bot token instead, click" />
|
||||||
<InlineUIContainer>
|
<InlineUIContainer>
|
||||||
<materialDesign:PackIcon Margin="1,0,0,-3" Kind="Account" />
|
<materialDesign:PackIcon
|
||||||
|
Margin="1,0,0,-3"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
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>
|
||||||
|
|
||||||
<!-- For bot token -->
|
<!-- For bot token -->
|
||||||
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
<TextBlock FontSize="18" Text="Please provide your bot token to authorize" />
|
<TextBlock
|
||||||
<TextBlock Margin="8,8,0,0" FontSize="14">
|
FontSize="18"
|
||||||
|
FontWeight="Light"
|
||||||
|
Text="Please provide your bot token to authorize" />
|
||||||
|
<TextBlock
|
||||||
|
Margin="8,8,0,0"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="Light">
|
||||||
<Run Text="1. Open Discord developer portal" />
|
<Run Text="1. Open Discord developer portal" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="2. Open your application's settings" />
|
<Run Text="2. Open your application's settings" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="3. Navigate to the" />
|
<Run Text="3. Navigate to the" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
|
<Run FontWeight="SemiBold" Text="Bot" />
|
||||||
<Run Text="section on the left" />
|
<Run Text="section on the left" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="4. Under" />
|
<Run Text="4. Under" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
|
<Run FontWeight="SemiBold" Text="Token" />
|
||||||
<Run Text="click" />
|
<Run Text="click" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
|
<Run FontWeight="SemiBold" Text="Copy" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
<TextBlock Margin="0,24,0,0" FontSize="14">
|
||||||
<Run Text="To authorize using user token instead, click" />
|
<Run Text="To authorize using user token instead, click" />
|
||||||
<InlineUIContainer>
|
<InlineUIContainer>
|
||||||
<materialDesign:PackIcon Margin="1,0,0,-1" Kind="Robot" />
|
<materialDesign:PackIcon
|
||||||
|
Margin="1,0,0,-1"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
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>
|
||||||
@@ -226,7 +249,7 @@
|
|||||||
<!-- Guilds -->
|
<!-- Guilds -->
|
||||||
<Border
|
<Border
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
BorderBrush="{DynamicResource DividerBrush}"
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
BorderThickness="0,0,1,0">
|
BorderThickness="0,0,1,0">
|
||||||
<ListBox
|
<ListBox
|
||||||
ItemsSource="{Binding AvailableGuilds}"
|
ItemsSource="{Binding AvailableGuilds}"
|
||||||
@@ -245,13 +268,15 @@
|
|||||||
Width="48"
|
Width="48"
|
||||||
Height="48"
|
Height="48"
|
||||||
Margin="12,4,12,4"
|
Margin="12,4,12,4"
|
||||||
Fill="{DynamicResource DividerBrush}" />
|
Fill="{DynamicResource MaterialDesignDivider}" />
|
||||||
|
|
||||||
<!-- Guild icon -->
|
<!-- Guild icon -->
|
||||||
<Ellipse
|
<Ellipse
|
||||||
Width="48"
|
Width="48"
|
||||||
Height="48"
|
Height="48"
|
||||||
Margin="12,4,12,4">
|
Margin="12,4,12,4"
|
||||||
|
Stroke="{DynamicResource MaterialDesignDivider}"
|
||||||
|
StrokeThickness="1">
|
||||||
<Ellipse.Fill>
|
<Ellipse.Fill>
|
||||||
<ImageBrush ImageSource="{Binding IconUrl}" />
|
<ImageBrush ImageSource="{Binding IconUrl}" />
|
||||||
</Ellipse.Fill>
|
</Ellipse.Fill>
|
||||||
@@ -284,8 +309,8 @@
|
|||||||
Margin="0"
|
Margin="0"
|
||||||
Padding="0"
|
Padding="0"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
BorderBrush="{DynamicResource DividerBrush}"
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
BorderThickness="0,1,0,0"
|
BorderThickness="0,0,0,1"
|
||||||
Header="{Binding Name}"
|
Header="{Binding Name}"
|
||||||
IsExpanded="False">
|
IsExpanded="False">
|
||||||
<ItemsPresenter />
|
<ItemsPresenter />
|
||||||
@@ -322,7 +347,6 @@
|
|||||||
Margin="3,8,8,8"
|
Margin="3,8,8,8"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
|
||||||
Text="{Binding Name, Mode=OneWay}" />
|
Text="{Binding Name, Mode=OneWay}" />
|
||||||
|
|
||||||
<!-- Is selected checkmark -->
|
<!-- Is selected checkmark -->
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||||
<Version>2.23</Version>
|
<Version>2.24</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -8,12 +8,13 @@
|
|||||||
|
|
||||||
DiscordChatExporter can be used to export message history from a [Discord](https://discord.com) channel to a file. It works with direct messages, group messages, server channels, supports Discord's dialect of markdown and all other rich media features.
|
DiscordChatExporter can be used to export message history from a [Discord](https://discord.com) channel to a file. It works with direct messages, group messages, server channels, supports Discord's dialect of markdown and all other rich media features.
|
||||||
|
|
||||||
_For guides and other info -- check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)._
|
Have questions? Check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki) first.
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
- **[Latest release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**
|
- **[Latest release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**
|
||||||
- [Docker image](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
|
- [Docker image](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
|
||||||
|
- [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli/): `yay -S discord-chat-exporter-cli`
|
||||||
- [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
- [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
||||||
|
|
||||||
Note: This application requires .NET Core runtime in order to run:
|
Note: This application requires .NET Core runtime in order to run:
|
||||||
@@ -37,4 +38,4 @@ Note: This application requires .NET Core runtime in order to run:
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
Reference in New Issue
Block a user