mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 15:14:37 +02:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5038b8541e | |||
| 1da80956dd | |||
| 0763a99765 | |||
| eda5b21684 | |||
| 2c7b054c24 | |||
| 520e023aff | |||
| 949c9d3f1e | |||
| fa825fce47 | |||
| 09acfcff59 | |||
| ddb4856516 | |||
| c4007d4a80 | |||
| 2a432c0ed2 | |||
| c5eeb5f464 | |||
| a6bf546e79 | |||
| 239c47c06e | |||
| 19f678ca01 | |||
| 08442f21b7 | |||
| 921f348769 | |||
| a25e809671 | |||
| fa80c82468 | |||
| 91f4f02a35 | |||
| 6d2880ce26 | |||
| 355b8cb8cf | |||
| 82945ac3cf | |||
| 5009b90a3e | |||
| 1495ed9b90 | |||
| be66c74c08 | |||
| 6f54c09d96 | |||
| 45926b0990 | |||
| 25d29dc079 | |||
| efde9931c9 | |||
| 2c3b461d49 | |||
| b405052fd6 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,3 +1,26 @@
|
|||||||
|
### 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)
|
||||||
|
|
||||||
|
- [CLI] Added a command to export all channels across all servers. Use `exportall` to do it. (Thanks [@wyattscarpenter](https://github.com/wyattscarpenter))
|
||||||
|
- [HTML] Fixed an issue where Whitney fonts were not being loaded properly, causing the browser to fall back to Helvetica.
|
||||||
|
- Fixed an issue where self-contained export crashed occasionally. This usually happened when the server hosting the file did not serve the stream properly. Such files are now ignored after the first failed attempt.
|
||||||
|
|
||||||
|
### v2.22 (12-Aug-2020)
|
||||||
|
|
||||||
|
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||||
|
- [GUI] Improved exporting options by adding a set of controls that can be used to limit the date range of the export down to minutes. Previously it was only possible to configure the date range without the time component. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||||
|
- [HTML] Fixed an issue where the export didn't reflect changes in nicknames between messages sent by bots. This affected chat logs that contained interactions with the Tupperbox bot. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||||
|
- [CLI] Fixed an issue where the application crashed if there were two environment variables defined that had the same name but in different case.
|
||||||
|
|
||||||
### v2.21.2 (30-Jul-2020)
|
### v2.21.2 (30-Jul-2020)
|
||||||
|
|
||||||
- [GUI] When copy-pasting token, any surrounding spaces are now discarded, in addition to double quotes.
|
- [GUI] When copy-pasting token, any surrounding spaces are now discarded, in addition to double quotes.
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CliFx;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
|
{
|
||||||
|
[Command("exportall", Description = "Export all accessible channels.")]
|
||||||
|
public class ExportAllCommand : ExportMultipleCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("include-dm",
|
||||||
|
Description = "Include direct message channels.")]
|
||||||
|
public bool IncludeDirectMessages { get; set; } = true;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
|
// Aggregate channels from all guilds
|
||||||
|
await foreach (var guild in GetDiscordClient().GetUserGuildsAsync())
|
||||||
|
{
|
||||||
|
// Skip DMs if instructed to
|
||||||
|
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await foreach (var channel in GetDiscordClient().GetGuildChannelsAsync(guild.Id))
|
||||||
|
{
|
||||||
|
channels.Add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,8 +12,8 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||||
await ExportMultipleAsync(console, dmChannels);
|
await ExportMultipleAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,13 +9,14 @@ 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)
|
||||||
{
|
{
|
||||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||||
await ExportMultipleAsync(console, guildChannels);
|
await ExportMultipleAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,17 +7,18 @@ using DiscordChatExporter.Domain.Utilities;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
[Command("channels", Description = "Get the list of channels in specified 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)
|
||||||
{
|
{
|
||||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||||
|
|
||||||
foreach (var channel in guildChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||||
|
|
||||||
foreach (var channel in dmChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.1" />
|
<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,44 +9,27 @@ 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) {}
|
||||||
|
|
||||||
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
|
||||||
|
await Http.ResponsePolicy.ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
|
|
||||||
public DateTimeOffset? EditedTimestamp { get; }
|
public DateTimeOffset? EditedTimestamp { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset? CallEndedTimestamp { get; }
|
||||||
|
|
||||||
public bool IsPinned { get; }
|
public bool IsPinned { get; }
|
||||||
|
|
||||||
public string Content { get; }
|
public string Content { get; }
|
||||||
@@ -51,6 +53,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
User author,
|
User author,
|
||||||
DateTimeOffset timestamp,
|
DateTimeOffset timestamp,
|
||||||
DateTimeOffset? editedTimestamp,
|
DateTimeOffset? editedTimestamp,
|
||||||
|
DateTimeOffset? callEndedTimestamp,
|
||||||
bool isPinned,
|
bool isPinned,
|
||||||
string content,
|
string content,
|
||||||
IReadOnlyList<Attachment> attachments,
|
IReadOnlyList<Attachment> attachments,
|
||||||
@@ -63,6 +66,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
Author = author;
|
Author = author;
|
||||||
Timestamp = timestamp;
|
Timestamp = timestamp;
|
||||||
EditedTimestamp = editedTimestamp;
|
EditedTimestamp = editedTimestamp;
|
||||||
|
CallEndedTimestamp = callEndedTimestamp;
|
||||||
IsPinned = isPinned;
|
IsPinned = isPinned;
|
||||||
Content = content;
|
Content = content;
|
||||||
Attachments = attachments;
|
Attachments = attachments;
|
||||||
@@ -82,6 +86,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
var author = json.GetProperty("author").Pipe(User.Parse);
|
var author = json.GetProperty("author").Pipe(User.Parse);
|
||||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||||
|
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")?.GetDateTimeOffset();
|
||||||
var type = (MessageType) json.GetProperty("type").GetInt32();
|
var type = (MessageType) json.GetProperty("type").GetInt32();
|
||||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
||||||
|
|
||||||
@@ -89,7 +94,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
{
|
{
|
||||||
MessageType.RecipientAdd => "Added a recipient.",
|
MessageType.RecipientAdd => "Added a recipient.",
|
||||||
MessageType.RecipientRemove => "Removed a recipient.",
|
MessageType.RecipientRemove => "Removed a recipient.",
|
||||||
MessageType.Call => "Started a call.",
|
MessageType.Call =>
|
||||||
|
$"Started a call that lasted {callEndedTimestamp?.Pipe(t => t - timestamp).Pipe(t => (int) t.TotalMinutes) ?? 0} minutes.",
|
||||||
MessageType.ChannelNameChange => "Changed the channel name.",
|
MessageType.ChannelNameChange => "Changed the channel name.",
|
||||||
MessageType.ChannelIconChange => "Changed the channel icon.",
|
MessageType.ChannelIconChange => "Changed the channel icon.",
|
||||||
MessageType.ChannelPinnedMessage => "Pinned a message.",
|
MessageType.ChannelPinnedMessage => "Pinned a message.",
|
||||||
@@ -119,6 +125,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
author,
|
author,
|
||||||
timestamp,
|
timestamp,
|
||||||
editedTimestamp,
|
editedTimestamp,
|
||||||
|
callEndedTimestamp,
|
||||||
isPinned,
|
isPinned,
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Import Project="../DiscordChatExporter.props" />
|
<Import Project="../DiscordChatExporter.props" />
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MiniRazor" Version="1.0.1" />
|
<PackageReference Include="MiniRazor" Version="1.1.0" />
|
||||||
<PackageReference Include="Polly" Version="7.2.1" />
|
<PackageReference Include="Polly" Version="7.2.1" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -84,8 +89,12 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
return relativeFilePath;
|
return relativeFilePath;
|
||||||
}
|
}
|
||||||
catch (HttpRequestException)
|
// Try to catch only exceptions related to failed HTTP requests
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
// TODO: add logging so we can be more liberal with catching exceptions
|
||||||
// We don't want this to crash the exporting process in case of failure
|
// 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 _pathMap[url] = filePath;
|
return _pathCache[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);
|
||||||
|
|||||||
@@ -2,31 +2,31 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
||||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
||||||
var userNick = userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
var userNick = (Model.MessageGroup.Author.IsBot ? Model.MessageGroup.Author.Name : (userMember?.Nick ?? Model.MessageGroup.Author.Name));
|
||||||
|
|
||||||
var userColorStyle = userColor != null
|
var userColorStyle = userColor != null
|
||||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ using System.Threading.Tasks;
|
|||||||
using MiniRazor;
|
using MiniRazor;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
[assembly: InternalsVisibleTo("RazorAssembly")]
|
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PreambleTemplateAssemblyName)]
|
||||||
|
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.MessageGroupTemplateAssemblyName)]
|
||||||
|
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PostambleTemplateAssemblyName)]
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||||
{
|
{
|
||||||
internal partial class TemplateBundle
|
internal partial class TemplateBundle
|
||||||
{
|
{
|
||||||
|
public const string PreambleTemplateAssemblyName = "RazorAssembly_Preamble";
|
||||||
|
public const string MessageGroupTemplateAssemblyName = "RazorAssembly_MessageGroup";
|
||||||
|
public const string PostambleTemplateAssemblyName = "RazorAssembly_Postamble";
|
||||||
|
|
||||||
public MiniRazorTemplateDescriptor PreambleTemplate { get; }
|
public MiniRazorTemplateDescriptor PreambleTemplate { get; }
|
||||||
|
|
||||||
public MiniRazorTemplateDescriptor MessageGroupTemplate { get; }
|
public MiniRazorTemplateDescriptor MessageGroupTemplate { get; }
|
||||||
@@ -44,11 +50,11 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
|||||||
var postambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
var postambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||||
.GetManifestResourceString($"{ns}.PostambleTemplate.cshtml");
|
.GetManifestResourceString($"{ns}.PostambleTemplate.cshtml");
|
||||||
|
|
||||||
var engine = new MiniRazorTemplateEngine("RazorAssembly", ns);
|
var engine = new MiniRazorTemplateEngine();
|
||||||
|
|
||||||
var preambleTemplate = engine.Compile(preambleTemplateSource);
|
var preambleTemplate = engine.Compile(preambleTemplateSource, PreambleTemplateAssemblyName, ns);
|
||||||
var messageGroupTemplate = engine.Compile(messageGroupTemplateSource);
|
var messageGroupTemplate = engine.Compile(messageGroupTemplateSource, MessageGroupTemplateAssemblyName, ns);
|
||||||
var postambleTemplate = engine.Compile(postambleTemplateSource);
|
var postambleTemplate = engine.Compile(postambleTemplateSource, PostambleTemplateAssemblyName, ns);
|
||||||
|
|
||||||
return new TemplateBundle(preambleTemplate, messageGroupTemplate, postambleTemplate);
|
return new TemplateBundle(preambleTemplate, messageGroupTemplate, postambleTemplate);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -197,6 +200,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteString("type", message.Type.ToString());
|
_writer.WriteString("type", message.Type.ToString());
|
||||||
_writer.WriteString("timestamp", message.Timestamp);
|
_writer.WriteString("timestamp", message.Timestamp);
|
||||||
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
||||||
|
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
|
||||||
_writer.WriteBoolean("isPinned", message.IsPinned);
|
_writer.WriteBoolean("isPinned", message.IsPinned);
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:s="https://github.com/canton7/Stylet">
|
xmlns:s="https://github.com/canton7/Stylet">
|
||||||
<Application.Resources>
|
<Application.Resources>
|
||||||
<s:ApplicationLoader>
|
<s:ApplicationLoader>
|
||||||
@@ -13,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}" />
|
||||||
@@ -83,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" />
|
||||||
@@ -93,23 +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
|
<Style
|
||||||
x:Key="MaterialDesignFlatDarkButton"
|
x:Key="MaterialDesignFlatDarkButton"
|
||||||
@@ -136,6 +80,89 @@
|
|||||||
</Trigger>
|
</Trigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate TargetType="Expander">
|
||||||
|
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
|
||||||
|
<DockPanel Background="{TemplateBinding Background}">
|
||||||
|
<ToggleButton
|
||||||
|
Name="HeaderSite"
|
||||||
|
BorderThickness="0"
|
||||||
|
Content="{TemplateBinding Header}"
|
||||||
|
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
|
||||||
|
ContentTemplate="{TemplateBinding HeaderTemplate}"
|
||||||
|
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
|
||||||
|
Cursor="Hand"
|
||||||
|
DockPanel.Dock="Top"
|
||||||
|
Focusable="False"
|
||||||
|
Foreground="{TemplateBinding Foreground}"
|
||||||
|
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||||
|
IsTabStop="False"
|
||||||
|
Opacity=".87"
|
||||||
|
Style="{StaticResource MaterialDesignExpanderDownHeaderStyle}"
|
||||||
|
TextElement.FontSize="15" />
|
||||||
|
<Border
|
||||||
|
Name="ContentSite"
|
||||||
|
DockPanel.Dock="Bottom"
|
||||||
|
Visibility="Collapsed">
|
||||||
|
<StackPanel
|
||||||
|
Name="ContentPanel"
|
||||||
|
Margin="{TemplateBinding Padding}"
|
||||||
|
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||||
|
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
|
||||||
|
<ContentPresenter
|
||||||
|
Name="PART_Content"
|
||||||
|
ContentStringFormat="{TemplateBinding ContentStringFormat}"
|
||||||
|
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||||
|
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
|
||||||
|
Focusable="False" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<ControlTemplate.Triggers>
|
||||||
|
<Trigger Property="IsExpanded" Value="true">
|
||||||
|
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="ExpandDirection" Value="Right">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderRightHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
|
||||||
|
<Trigger Property="ExpandDirection" Value="Left">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderLeftHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
|
||||||
|
<Trigger Property="ExpandDirection" Value="Up">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderUpHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
|
||||||
|
<Trigger Property="ExpandDirection" Value="Down">
|
||||||
|
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
|
||||||
|
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||||
|
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||||
|
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderDownHeaderStyle}" />
|
||||||
|
</Trigger>
|
||||||
|
</ControlTemplate.Triggers>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
</s:ApplicationLoader>
|
</s:ApplicationLoader>
|
||||||
</Application.Resources>
|
</Application.Resources>
|
||||||
</Application>
|
</Application>
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Converters
|
||||||
|
{
|
||||||
|
[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))]
|
||||||
|
public class TimeSpanToDateTimeConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static TimeSpanToDateTimeConverter Instance { get; } = new TimeSpanToDateTimeConverter();
|
||||||
|
|
||||||
|
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is TimeSpan timeSpanValue)
|
||||||
|
return DateTime.Today.Add(timeSpanValue);
|
||||||
|
|
||||||
|
return default(DateTime?);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
if (value is DateTime dateTimeValue)
|
||||||
|
return dateTimeValue.TimeOfDay;
|
||||||
|
|
||||||
|
return default(TimeSpan?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,23 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
|
|
||||||
public ExportFormat SelectedFormat { get; set; }
|
public ExportFormat SelectedFormat { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset? After { get; set; }
|
// This date/time abomination is required because we use separate controls to set these
|
||||||
|
|
||||||
public DateTimeOffset? Before { get; set; }
|
public DateTimeOffset? AfterDate { get; set; }
|
||||||
|
|
||||||
|
public bool IsAfterDateSet => AfterDate != null;
|
||||||
|
|
||||||
|
public TimeSpan? AfterTime { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
|
||||||
|
|
||||||
|
public DateTimeOffset? BeforeDate { get; set; }
|
||||||
|
|
||||||
|
public bool IsBeforeDateSet => BeforeDate != null;
|
||||||
|
|
||||||
|
public TimeSpan? BeforeTime { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
|
||||||
|
|
||||||
public int? PartitionLimit { get; set; }
|
public int? PartitionLimit { get; set; }
|
||||||
|
|
||||||
@@ -60,17 +74,17 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
_settingsService.LastPartitionLimit = PartitionLimit;
|
_settingsService.LastPartitionLimit = PartitionLimit;
|
||||||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||||
|
|
||||||
// Clamp 'after' and 'before' values
|
|
||||||
if (After > Before)
|
|
||||||
After = Before;
|
|
||||||
if (Before < After)
|
|
||||||
Before = After;
|
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -84,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,30 +8,42 @@ 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)
|
||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_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);
|
||||||
|
|
||||||
@@ -150,11 +170,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||||
await foreach (var guild in discord.GetUserGuildsAsync())
|
await foreach (var guild in discord.GetUserGuildsAsync())
|
||||||
{
|
{
|
||||||
var channels = await discord.GetGuildChannelsAsync(guild.Id);
|
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
|
||||||
guildChannelMap[guild] = channels
|
|
||||||
.OrderBy(c => c.Category)
|
|
||||||
.ThenBy(c => c.Name)
|
|
||||||
.ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GuildChannelMap = guildChannelMap;
|
GuildChannelMap = guildChannelMap;
|
||||||
@@ -182,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;
|
||||||
@@ -199,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>
|
||||||
@@ -82,36 +83,58 @@
|
|||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="16,8"
|
Margin="16,8,16,4"
|
||||||
materialDesign:HintAssist.Hint="From (optional)"
|
materialDesign:HintAssist.Hint="After (date)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
DisplayDateEnd="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
SelectedDate="{Binding After, 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"
|
||||||
Margin="16,8"
|
Margin="16,8,16,4"
|
||||||
materialDesign:HintAssist.Hint="To (optional)"
|
materialDesign:HintAssist.Hint="Before (date)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
DisplayDateStart="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
SelectedDate="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
ToolTip="If this is set, only messages sent before this date will be exported" />
|
ToolTip="Only include messages sent before this date" />
|
||||||
|
<materialDesign:TimePicker
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,4,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="After (time)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
IsEnabled="{Binding IsAfterDateSet}"
|
||||||
|
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
|
ToolTip="Only include messages sent after this time" />
|
||||||
|
<materialDesign:TimePicker
|
||||||
|
Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="16,4,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Before (time)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
IsEnabled="{Binding IsBeforeDateSet}"
|
||||||
|
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
|
ToolTip="Only include messages sent before this time" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Partitioning -->
|
<!-- Partitioning -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialDesign:HintAssist.Hint="Messages per partition (optional)"
|
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" />
|
||||||
@@ -120,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"
|
||||||
@@ -141,18 +164,13 @@
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
x:Name="AdvancedSectionToggleButton"
|
x:Name="AdvancedSectionToggleButton"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="8"
|
|
||||||
Cursor="Hand"
|
|
||||||
Loaded="AdvancedSectionToggleButton_OnLoaded"
|
|
||||||
Style="{DynamicResource MaterialDesignFlatToggleButton}"
|
|
||||||
ToolTip="Show advanced options">
|
|
||||||
<ToggleButton.Content>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="24"
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
Kind="Menu" />
|
Margin="12"
|
||||||
</ToggleButton.Content>
|
Cursor="Hand"
|
||||||
</ToggleButton>
|
IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
|
||||||
|
Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
|
||||||
|
ToolTip="Toggle advanced options" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.Windows;
|
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
|
||||||
{
|
{
|
||||||
public partial class ExportSetupView
|
public partial class ExportSetupView
|
||||||
{
|
{
|
||||||
@@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AdvancedSectionToggleButton_OnLoaded(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is ExportSetupViewModel vm)
|
|
||||||
AdvancedSectionToggleButton.IsChecked = vm.IsAdvancedSectionDisplayedByDefault;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
||||||
|
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
|
||||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
|
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
|
||||||
@@ -23,6 +24,18 @@
|
|||||||
<Window.TaskbarItemInfo>
|
<Window.TaskbarItemInfo>
|
||||||
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
|
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
|
||||||
</Window.TaskbarItemInfo>
|
</Window.TaskbarItemInfo>
|
||||||
|
<Window.Resources>
|
||||||
|
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
||||||
|
<CollectionViewSource.GroupDescriptions>
|
||||||
|
<PropertyGroupDescription PropertyName="Category" />
|
||||||
|
</CollectionViewSource.GroupDescriptions>
|
||||||
|
<CollectionViewSource.SortDescriptions>
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Category" />
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
|
||||||
|
</CollectionViewSource.SortDescriptions>
|
||||||
|
</CollectionViewSource>
|
||||||
|
</Window.Resources>
|
||||||
|
|
||||||
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -33,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" />
|
||||||
@@ -111,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"
|
||||||
@@ -123,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 -->
|
||||||
@@ -138,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">
|
||||||
@@ -164,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>
|
||||||
@@ -213,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}"
|
||||||
@@ -232,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>
|
||||||
@@ -253,12 +291,37 @@
|
|||||||
<Border Grid.Column="1">
|
<Border Grid.Column="1">
|
||||||
<ListBox
|
<ListBox
|
||||||
HorizontalContentAlignment="Stretch"
|
HorizontalContentAlignment="Stretch"
|
||||||
ItemsSource="{Binding AvailableChannels}"
|
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
|
||||||
SelectionMode="Extended"
|
SelectionMode="Extended"
|
||||||
TextSearch.TextPath="Model.Name">
|
TextSearch.TextPath="Model.Name"
|
||||||
|
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
|
||||||
<i:Interaction.Behaviors>
|
<i:Interaction.Behaviors>
|
||||||
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
|
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
|
||||||
</i:Interaction.Behaviors>
|
</i:Interaction.Behaviors>
|
||||||
|
<ListBox.GroupStyle>
|
||||||
|
<GroupStyle>
|
||||||
|
<GroupStyle.ContainerStyle>
|
||||||
|
<Style TargetType="{x:Type GroupItem}">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
|
||||||
|
<Expander
|
||||||
|
Margin="0"
|
||||||
|
Padding="0"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Header="{Binding Name}"
|
||||||
|
IsExpanded="False">
|
||||||
|
<ItemsPresenter />
|
||||||
|
</Expander>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</GroupStyle.ContainerStyle>
|
||||||
|
</GroupStyle>
|
||||||
|
</ListBox.GroupStyle>
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<Grid Margin="-8" Background="Transparent">
|
<Grid Margin="-8" Background="Transparent">
|
||||||
@@ -278,16 +341,13 @@
|
|||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Kind="Pound" />
|
Kind="Pound" />
|
||||||
|
|
||||||
<!-- Channel category / name -->
|
<!-- Channel name -->
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="3,8,8,8"
|
Margin="3,8,8,8"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="14">
|
FontSize="14"
|
||||||
<Run Foreground="{DynamicResource SecondaryTextBrush}" Text="{Binding Category, Mode=OneWay}" />
|
Text="{Binding Name, Mode=OneWay}" />
|
||||||
<Run Text="/" />
|
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding Name, Mode=OneWay}" />
|
|
||||||
</TextBlock>
|
|
||||||
|
|
||||||
<!-- Is selected checkmark -->
|
<!-- Is selected checkmark -->
|
||||||
<materialDesign:PackIcon
|
<materialDesign:PackIcon
|
||||||
|
|||||||
@@ -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.21.2</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:
|
||||||
|
|||||||
Reference in New Issue
Block a user