Compare commits

..

37 Commits

Author SHA1 Message Date
Tyrrrz 9df98b0405 Update version 2020-11-23 17:04:08 +02:00
Tyrrrz 0d2b9e8bc0 Refactor last PR 2020-11-23 16:53:22 +02:00
Andrew Kolos 53cebd82eb [CLI] Print error and exit if reuse-media option is used without media option (#433) 2020-11-22 20:45:19 +02:00
Tyrrrz 37fcee2f21 Fix relative path extraction in ExportContext when base directory is empty
Fixes #437
Fixes #434
2020-11-20 16:56:30 +02:00
Tyrrrz 047dccef7f Refactor last PR 2020-11-18 19:04:53 +02:00
sas41 cb47902d10 [HTML] Rich rendering support for audio and video embeds (#432) 2020-11-16 15:49:57 +02:00
Alexey Golub dca8b8ceb2 Update .NET runtime links 2020-11-11 15:23:43 +02:00
Tyrrrz a8e7c85b99 Update docker image references 2020-11-11 01:56:53 +02:00
Tyrrrz edd51e0838 Revert last change, .net5 doesn't work 2020-11-11 00:02:56 +02:00
Tyrrrz 5e04accdff Update build infra 2020-11-10 23:58:05 +02:00
Andrew Kolos 547bf0a463 [JSON] Add core user mentions to output (#416) 2020-10-25 23:00:30 +02:00
Tyrrrz 948fab7c86 Update readme 2020-10-25 01:53:32 +03:00
Tyrrrz 5038b8541e Update version 2020-10-24 21:26:34 +03:00
Tyrrrz 1da80956dd Refactor 2020-10-24 21:15:58 +03:00
Tyrrrz 0763a99765 [JSON] Fix incorrect embed color output 2020-10-24 17:45:54 +03:00
Tyrrrz eda5b21684 Update github actions 2020-10-24 00:02:32 +03:00
Tyrrrz 2c7b054c24 Update nuget packages 2020-10-24 00:02:32 +03:00
Andrew Kolos 520e023aff Stop redundantly downloading media when re-exporting (#395) 2020-10-23 16:38:15 +03:00
Alexey Golub 949c9d3f1e Update readme 2020-10-17 20:25:21 +03:00
Andrew Kolos fa825fce47 [GUI] Fix content bg color on first screen to match the second screen (#411) 2020-10-16 14:20:58 +03:00
Andrew Kolos 09acfcff59 Add retry policy to MediaDownloader to accommodate short network outages during download (#404) 2020-10-12 23:03:14 +03:00
Andrew Kolos ddb4856516 Add "unix" and "unixms" options for date format (#405) 2020-10-12 13:41:49 +03:00
Andrew Kolos c4007d4a80 [JSON] Add embed color (#403) 2020-10-10 16:10:56 +03:00
Alexey Golub 2a432c0ed2 Treat nullability warnings as errors 2020-10-07 15:33:59 +03:00
Alexey Golub c5eeb5f464 Update nuget packages 2020-10-07 15:33:47 +03:00
Alexey Golub a6bf546e79 [GUI] Clean up the UI 2020-10-05 17:41:09 +03:00
Alexey Golub 239c47c06e [GUI] Clean up theme management 2020-10-05 17:10:00 +03:00
Andrew Kolos 19f678ca01 [GUI] Add dark theme (#388) 2020-10-05 16:31:37 +03:00
Pegasis 08442f21b7 Add AUR package to README (#379) 2020-09-20 15:52:44 +03:00
Alexey Golub 921f348769 Update version 2020-09-14 15:37:36 +03:00
Alexey Golub a25e809671 Extend try/catch in media downloading to OperationCanceledException
Closes #372
2020-09-14 15:09:57 +03:00
Alexey Golub fa80c82468 Cleanup 2020-09-05 17:10:15 +03:00
Alexey Golub 91f4f02a35 Refactor last commit 2020-09-05 17:05:18 +03:00
wyattscarpenter 6d2880ce26 [CLI] Add command to export all channels across all guilds (#373) 2020-09-05 00:23:06 +03:00
Ahmed Massoud 355b8cb8cf Add call time for call status messages (#365) 2020-08-22 22:14:13 +03:00
Alexey Golub 82945ac3cf [HTML] Use CDN for Whitney fonts
Related to #322
2020-08-21 20:36:59 +03:00
Alexey Golub 5009b90a3e Update screenshots 2020-08-15 13:47:47 +03:00
58 changed files with 834 additions and 502 deletions
+47 -47
View File
@@ -3,64 +3,64 @@ name: CD
on: on:
push: push:
tags: tags:
- '*' - "*"
jobs: jobs:
build: build:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2.3.3
- name: Install .NET Core - name: Install .NET
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
- name: Build & publish (CLI) - name: Build & publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
- name: Build & publish (GUI) - name: Build & publish (GUI)
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
- name: Pack (CLI) - name: Pack (CLI)
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
shell: pwsh shell: pwsh
- name: Pack (GUI) - name: Pack (GUI)
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
shell: pwsh shell: pwsh
- name: Create release - name: Create release
id: create_release id: create_release
uses: actions/create-release@v1.0.1 uses: actions/create-release@v1.0.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
tag_name: ${{ github.ref }} tag_name: ${{ github.ref }}
release_name: ${{ github.ref }} release_name: ${{ github.ref }}
body: | body: |
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md) [Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
draft: false draft: false
prerelease: false prerelease: false
- name: Upload release asset (CLI) - name: Upload release asset (CLI)
uses: actions/upload-release-asset@v1.0.2 uses: actions/upload-release-asset@v1.0.2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
asset_name: DiscordChatExporter.CLI.zip asset_name: DiscordChatExporter.CLI.zip
asset_content_type: application/zip asset_content_type: application/zip
- name: Upload release asset (GUI) - name: Upload release asset (GUI)
uses: actions/upload-release-asset@v1.0.2 uses: actions/upload-release-asset@v1.0.2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
upload_url: ${{ steps.create_release.outputs.upload_url }} upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
asset_name: DiscordChatExporter.zip asset_name: DiscordChatExporter.zip
asset_content_type: application/zip asset_content_type: application/zip
+20 -20
View File
@@ -7,28 +7,28 @@ jobs:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2.3.3
- name: Install .NET Core - name: Install .NET
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
- name: Build & publish (CLI) - name: Build & publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
- name: Build & publish (GUI) - name: Build & publish (GUI)
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
- name: Upload build artifacts (CLI) - name: Upload build artifacts (CLI)
uses: actions/upload-artifact@v1 uses: actions/upload-artifact@v1
with: with:
name: DiscordChatExporter.CLI name: DiscordChatExporter.CLI
path: DiscordChatExporter.Cli/bin/Publish/ path: DiscordChatExporter.Cli/bin/Publish/
- name: Upload build artifact (GUI) - name: Upload build artifact (GUI)
uses: actions/upload-artifact@v1 uses: actions/upload-artifact@v1
with: with:
name: DiscordChatExporter name: DiscordChatExporter
path: DiscordChatExporter.Gui/bin/Publish/ path: DiscordChatExporter.Gui/bin/Publish/
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

+23
View File
@@ -1,3 +1,26 @@
### v2.25 (23-Nov-2020)
- [JSON] Added `mentions` property to the output. This contains users referenced inside the message text, but doesn't include mentions in embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
- [HTML] Added better previews for attached video and audio. They are now rendered using a browser native player element. The actual file path can still be retrieved by right clicking and selecting "Copy file path". (Thanks [@sas41](https://github.com/sas41))
- [CLI] Fixed an issue where using `--media` option without providing a rooted path via `--output` option triggered an error.
- [CLI] Fixed an issue where using `--reuse-media` option without also providing `--media` option was accepted, but didn't actually do anything. Now this produces an error instead. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
### 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) ### v2.22 (12-Aug-2020)
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem)) - [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
@@ -3,6 +3,7 @@ using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Utilities; using CliFx.Utilities;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Exporting;
@@ -11,25 +12,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 +60,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
Before, Before,
PartitionLimit, PartitionLimit,
ShouldDownloadMedia, ShouldDownloadMedia,
ShouldReuseMedia,
DateFormat DateFormat
); );
@@ -68,5 +81,17 @@ namespace DiscordChatExporter.Cli.Commands.Base
var channel = await GetDiscordClient().GetChannelAsync(channelId); var channel = await GetDiscordClient().GetChannelAsync(channelId);
await ExportAsync(console, channel); await ExportAsync(console, channel);
} }
public override ValueTask ExecuteAsync(IConsole console)
{
if (ShouldReuseMedia && !ShouldDownloadMedia)
{
throw new CommandException(
"The --reuse-media option cannot be used without the --media option."
);
}
return default;
}
} }
} }
@@ -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,14 @@ 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 base.ExecuteAsync(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.2" /> <PackageReference Include="CliFx" Version="1.5.0" />
<PackageReference Include="Gress" Version="1.2.0" /> <PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
</ItemGroup> </ItemGroup>
@@ -9,50 +9,33 @@ using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions; using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal; using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using Polly;
namespace DiscordChatExporter.Domain.Discord namespace DiscordChatExporter.Domain.Discord
{ {
public class DiscordClient public class DiscordClient
{ {
private readonly HttpClient _httpClient;
private readonly AuthToken _token; private readonly AuthToken _token;
private readonly HttpClient _httpClient = Singleton.HttpClient;
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute); private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
public DiscordClient(AuthToken token) public DiscordClient(HttpClient httpClient, AuthToken token)
{ {
_httpClient = httpClient;
_token = token; _token = token;
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
// finally wait the specified amount of time, based on how many requests have failed in a row.
_httpRequestPolicy = Policy
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
.WaitAndRetryAsync(6,
(i, result, ctx) =>
{
if (i <= 3)
return TimeSpan.FromSeconds(2 * i);
if (i <= 5)
return TimeSpan.FromSeconds(5 * i);
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
},
(response, timespan, retryCount, context) => Task.CompletedTask
);
} }
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () => public DiscordClient(AuthToken token)
{ : this(Http.Client, token) {}
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthorizationHeader();
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
}); await Http.ResponsePolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthorizationHeader();
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
});
private async ValueTask<JsonElement> GetJsonResponseAsync(string url) private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
{ {
@@ -1,6 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common; using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
@@ -20,11 +20,14 @@ namespace DiscordChatExporter.Domain.Discord.Models
public int? Height { get; } public int? Height { get; }
public bool IsImage => public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName));
ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
public bool IsVideo => VideoFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsAudio => AudioFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsSpoiler => public bool IsSpoiler =>
IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal); (IsImage || IsVideo || IsAudio) && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
public FileSize FileSize { get; } public FileSize FileSize { get; }
@@ -43,7 +46,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
public partial class Attachment public partial class Attachment
{ {
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"}; private static readonly HashSet<string> ImageFileExtensions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"};
private static readonly HashSet<string> VideoFileExtensions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{".mp4", ".webm"};
private static readonly HashSet<string> AudioFileExtensions =
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{".mp3", ".wav", ".ogg", ".flac", ".m4a"};
public static Attachment Parse(JsonElement json) public static Attachment Parse(JsonElement json)
{ {
@@ -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,
@@ -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);
@@ -69,10 +74,13 @@ namespace DiscordChatExporter.Domain.Exporting
{ {
var filePath = await _mediaDownloader.DownloadAsync(url); var filePath = await _mediaDownloader.DownloadAsync(url);
// 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); // Base directory path may be null if the file is stored at the root or relative to working directory.
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath)
? Path.GetRelativePath(Request.OutputBaseDirPath, filePath)
: 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 +92,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 _pathCache[url] = filePath;
return _pathMap[url] = filePath;
} }
} }
internal partial class MediaDownloader internal partial class MediaDownloader
{ {
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16); private static string GetUrlHash(string url)
{
using var hash = SHA256.Create();
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
}
private static string GetFileNameFromUrl(string url) private static string GetFileNameFromUrl(string url)
{ {
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value; var urlHash = GetUrlHash(url);
var fileName = !string.IsNullOrWhiteSpace(originalFileName) // Try to extract file name from URL
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(50)}{Path.GetExtension(originalFileName)}" var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
: GetRandomFileName();
return PathEx.EscapePath(fileName); // If it's not there, just use the URL hash as the file name
if (string.IsNullOrWhiteSpace(fileName))
return urlHash;
// Otherwise, use the original file name but inject the hash in the middle
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
var fileExtension = Path.GetExtension(fileName);
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
} }
} }
} }
@@ -72,7 +72,9 @@ namespace DiscordChatExporter.Domain.Exporting
internal partial class MessageExporter internal partial class MessageExporter
{ {
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex) private static string GetPartitionFilePath(
string baseFilePath,
int partitionIndex)
{ {
// First partition - don't change file name // First partition - don't change file name
if (partitionIndex <= 0) if (partitionIndex <= 0)
@@ -82,16 +84,17 @@ namespace DiscordChatExporter.Domain.Exporting
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
var fileExt = Path.GetExtension(baseFilePath); var fileExt = Path.GetExtension(baseFilePath);
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}"; var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
// Generate new path
var dirPath = Path.GetDirectoryName(baseFilePath); var dirPath = Path.GetDirectoryName(baseFilePath);
if (!string.IsNullOrWhiteSpace(dirPath))
return Path.Combine(dirPath, fileName);
return fileName; return !string.IsNullOrWhiteSpace(dirPath)
? Path.Combine(dirPath, fileName)
: fileName;
} }
private static MessageWriter CreateMessageWriter(string filePath, ExportFormat format, ExportContext context) private static MessageWriter CreateMessageWriter(
string filePath,
ExportFormat format,
ExportContext context)
{ {
// Stream will be disposed by the underlying writer // Stream will be disposed by the underlying writer
var stream = File.Create(filePath); var stream = File.Create(filePath);
@@ -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;
} }
@@ -49,13 +49,18 @@ img {
.markdown { .markdown {
max-width: 100%; max-width: 100%;
white-space: pre-wrap;
line-height: 1.3; line-height: 1.3;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.preserve-whitespace {
white-space: pre-wrap;
}
.spoiler { .spoiler {
width: fit-content; /* width: fit-content; */
display: inline-block;
/* This is more consistent across browsers, the old attribute worked well under Chrome but not FireFox. */
} }
.spoiler--hidden { .spoiler--hidden {
@@ -84,24 +89,28 @@ img {
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1); box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
} }
.spoiler--hidden .spoiler-image img { .spoiler--hidden .spoiler-image * {
filter: blur(44px); filter: blur(44px);
} }
.spoiler--hidden .spoiler-image:after { .spoiler--hidden .spoiler-image:after {
content: "SPOILER"; content: "SPOILER";
color: #dcddde; color: #dcddde;
background-color: rgba(0, 0, 0, 0.6); background-color: rgba(0, 0, 0, 0.6);
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
font-weight: 600; font-weight: 600;
padding: 0.5em 0.7em; /* padding: 0.5em 0.7em; */
border-radius: 20px; padding: 100%;
letter-spacing: 0.05em; /* This ruins those beutifully rounded buttons, but it's needed to prevent a FireFox bug with video and audio elemnts. */
font-size: 0.9em; /* The bug is that you can click trough the spoiler layer and play the video or audio, I could not identify the cause. */
} /* I leave this here, in case someone is brave enough to venture in to madness that is undocumented browser behaviour. */
border-radius: 20px;
letter-spacing: 0.05em;
font-size: 0.9em;
}
.spoiler--hidden:hover .spoiler-image:after { .spoiler--hidden:hover .spoiler-image:after {
color: #fff; color: #fff;
@@ -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 = (Model.MessageGroup.Author.IsBot ? Model.MessageGroup.Author.Name : (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})"
@@ -40,36 +40,51 @@
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null; var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id"> <div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
<div class="chatlog__content"> @if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp != null)
<div class="markdown">@Raw(FormatMarkdown(message.Content)) @if (message.EditedTimestamp != null) {<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>}</div> {
</div> <div class="chatlog__content">
<div class="markdown">
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
@if (message.EditedTimestamp != null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
}
</div>
</div>
}
@foreach (var attachment in message.Attachments) @foreach (var attachment in message.Attachments)
{ {
<div class="chatlog__attachment"> <div class="chatlog__attachment">
@if (attachment.IsSpoiler) <div class="@(attachment.IsSpoiler ? "spoiler spoiler--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
{ <div class="@(attachment.IsSpoiler ? "spoiler-image" : "")">
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
<div class="spoiler-image">
<a href="@await ResolveUrlAsync(attachment.Url)">
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
</a>
</div>
</div>
}
else
{
<a href="@await ResolveUrlAsync(attachment.Url)">
@if (attachment.IsImage) @if (attachment.IsImage)
{ {
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment"> <a href="@await ResolveUrlAsync(attachment.Url)">
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")">
</a>
}
else if (attachment.IsVideo)
{
<video controls class="chatlog__attachment-thumbnail">
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
</video>
}
else if (attachment.IsAudio)
{
<audio controls class="chatlog__attachment-thumbnail">
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
</audio>
} }
else else
{ {
@($"Attachment: {attachment.FileName} ({attachment.FileSize})") <a href="@await ResolveUrlAsync(attachment.Url)">
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
</a>
} }
</a> </div>
} </div>
</div> </div>
} }
@@ -119,12 +134,12 @@
@if (!string.IsNullOrWhiteSpace(embed.Url)) @if (!string.IsNullOrWhiteSpace(embed.Url))
{ {
<a class="chatlog__embed-title-link" href="@embed.Url"> <a class="chatlog__embed-title-link" href="@embed.Url">
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div> <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a> </a>
} }
else else
{ {
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div> <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
} }
</div> </div>
} }
@@ -132,7 +147,7 @@
@if (!string.IsNullOrWhiteSpace(embed.Description)) @if (!string.IsNullOrWhiteSpace(embed.Description))
{ {
<div class="chatlog__embed-description"> <div class="chatlog__embed-description">
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Description))</div> <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
</div> </div>
} }
@@ -147,14 +162,14 @@
@if (!string.IsNullOrWhiteSpace(field.Name)) @if (!string.IsNullOrWhiteSpace(field.Name))
{ {
<div class="chatlog__embed-field-name"> <div class="chatlog__embed-field-name">
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Name))</div> <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
</div> </div>
} }
@if (!string.IsNullOrWhiteSpace(field.Value)) @if (!string.IsNullOrWhiteSpace(field.Value))
{ {
<div class="chatlog__embed-field-value"> <div class="chatlog__embed-field-value">
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Value))</div> <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
</div> </div>
} }
</div> </div>
@@ -190,22 +205,22 @@
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon"> <img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
} }
<span class="chatlog__embed-footer-text"> <span class="chatlog__embed-footer-text">
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text)) @if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
{ {
@embed.Footer.Text @embed.Footer.Text
} }
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null) @if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
{ {
@(" • ") @(" • ")
} }
@if (embed.Timestamp != null) @if (embed.Timestamp != null)
{ {
@FormatDate(embed.Timestamp.Value) @FormatDate(embed.Timestamp.Value)
} }
</span> </span>
</div> </div>
} }
</div> </div>
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models; using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors; using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Domain.Internal.Extensions; using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Markdown.Ast;
namespace DiscordChatExporter.Domain.Exporting.Writers namespace DiscordChatExporter.Domain.Exporting.Writers
{ {
@@ -114,6 +115,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);
@@ -156,6 +160,20 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
await _writer.FlushAsync(); await _writer.FlushAsync();
} }
private async ValueTask WriteMentionAsync(User mentionedUser)
{
_writer.WriteStartObject();
_writer.WriteString("id", mentionedUser.Id);
_writer.WriteString("name", mentionedUser.Name);
_writer.WriteNumber("discriminator", mentionedUser.Discriminator);
_writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name);
_writer.WriteBoolean("isBot", mentionedUser.IsBot);
_writer.WriteEndObject();
await _writer.FlushAsync();
}
public override async ValueTask WritePreambleAsync() public override async ValueTask WritePreambleAsync()
{ {
// Root object (start) // Root object (start)
@@ -197,6 +215,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
@@ -235,6 +254,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteEndArray(); _writer.WriteEndArray();
// Mentions
_writer.WriteStartArray("mentions");
foreach (var mention in message.MentionedUsers)
await WriteMentionAsync(mention);
_writer.WriteEndArray();
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(); await _writer.FlushAsync();
@@ -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);
+12 -73
View File
@@ -14,69 +14,15 @@
<!-- Merged dictionaries --> <!-- Merged dictionaries -->
<ResourceDictionary.MergedDictionaries> <ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" /> <!-- Colors are irrelevant because they are overriden in runtime -->
<materialDesign:BundledTheme
BaseTheme="Light"
PrimaryColor="Blue"
SecondaryColor="Blue" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" /> <ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
</ResourceDictionary.MergedDictionaries> </ResourceDictionary.MergedDictionaries>
<!-- Colors -->
<Color x:Key="PrimaryColor">#343838</Color>
<Color x:Key="PrimaryLightColor">#5E6262</Color>
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
<Color x:Key="AccentColor">#F9A825</Color>
<Color x:Key="AccentDarkColor">#C17900</Color>
<Color x:Key="TextColor">#000000</Color>
<Color x:Key="InverseTextColor">#FFFFFF</Color>
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="PrimaryTextBrush"
Opacity="0.87"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="SecondaryTextBrush"
Opacity="0.64"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="DimTextBrush"
Opacity="0.45"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="PrimaryInverseTextBrush"
Opacity="1"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="SecondaryInverseTextBrush"
Opacity="0.7"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="DimInverseTextBrush"
Opacity="0.52"
Color="{DynamicResource InverseTextColor}" />
<SolidColorBrush
x:Key="AccentTextBrush"
Opacity="1"
Color="{DynamicResource AccentColor}" />
<SolidColorBrush
x:Key="AccentDarkTextBrush"
Opacity="1"
Color="{DynamicResource AccentDarkColor}" />
<SolidColorBrush
x:Key="DividerBrush"
Opacity="0.12"
Color="{DynamicResource TextColor}" />
<SolidColorBrush
x:Key="InverseDividerBrush"
Opacity="0.12"
Color="{DynamicResource InverseTextColor}" />
<!-- Styles --> <!-- Styles -->
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}"> <Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" /> <Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
@@ -84,7 +30,7 @@
<Setter Property="SnapsToDevicePixels" Value="True" /> <Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="TextElement.FontSize" Value="13" /> <Setter Property="TextElement.FontSize" Value="13" />
<Setter Property="TextElement.FontWeight" Value="Regular" /> <Setter Property="TextElement.FontWeight" Value="Regular" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource SecondaryTextBrush}" /> <Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" /> <Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" /> <Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
<Setter Property="UseLayoutRounding" Value="True" /> <Setter Property="UseLayoutRounding" Value="True" />
@@ -94,27 +40,20 @@
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}"> <Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" /> <Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
<Setter Property="Height" Value="2" /> <Setter Property="Height" Value="2" />
<Setter Property="Maximum" Value="1" /> <Setter Property="Maximum" Value="1" />
<Setter Property="Minimum" Value="0" /> <Setter Property="Minimum" Value="0" />
</Style> </Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}"> <Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}"> <Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}"> <Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}"> <Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style <Style
x:Key="MaterialDesignFlatDarkButton" x:Key="MaterialDesignFlatDarkButton"
+37 -4
View File
@@ -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);
} }
+9
View File
@@ -13,6 +13,15 @@ namespace DiscordChatExporter.Gui
{ {
public class Bootstrapper : Bootstrapper<RootViewModel> public class Bootstrapper : Bootstrapper<RootViewModel>
{ {
protected override void OnStart()
{
base.OnStart();
// Set default theme
// (preferred theme will be chosen later, once the settings are loaded)
App.SetLightTheme();
}
protected override void ConfigureIoC(IStyletIoCBuilder builder) protected override void ConfigureIoC(IStyletIoCBuilder builder)
{ {
base.ConfigureIoC(builder); base.ConfigureIoC(builder);
@@ -18,9 +18,7 @@ namespace DiscordChatExporter.Gui.Converters
return default(string); return default(string);
} }
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
{ throw new NotSupportedException();
throw new NotImplementedException();
}
} }
} }
@@ -18,11 +18,11 @@
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" /> <PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" /> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" /> <PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
<PackageReference Include="Onova" Version="2.6.1" /> <PackageReference Include="Onova" Version="2.6.2" />
<PackageReference Include="Stylet" Version="1.3.4" /> <PackageReference Include="Stylet" Version="1.3.4" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<PackageReference Include="PropertyChanged.Fody" Version="3.2.8" PrivateAssets="all" /> <PackageReference Include="PropertyChanged.Fody" Version="3.2.10" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+10
View File
@@ -11,6 +11,16 @@
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation> <xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
</xs:annotation> </xs:annotation>
</xs:attribute> </xs:attribute>
<xs:attribute name="TriggerDependentProperties" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if the Dependent properties feature is enabled.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="EnableIsChangedProperty" type="xs:boolean">
<xs:annotation>
<xs:documentation>Used to control if the IsChanged property feature is enabled.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="EventInvokerNames" type="xs:string"> <xs:attribute name="EventInvokerNames" type="xs:string">
<xs:annotation> <xs:annotation>
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation> <xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
@@ -0,0 +1,9 @@
using System.Windows.Media;
namespace DiscordChatExporter.Gui.Internal
{
internal static class MediaColor
{
public static Color FromHex(string hex) => (Color) ColorConverter.ConvertFromString(hex);
}
}
@@ -11,8 +11,7 @@ namespace DiscordChatExporter.Gui.Internal
UseShellExecute = true UseShellExecute = true
}; };
using (Process.Start(startInfo)) using (Process.Start(startInfo)) {}
{ }
} }
} }
} }
@@ -6,14 +6,18 @@ namespace DiscordChatExporter.Gui.Services
{ {
public class SettingsService : SettingsManager public class SettingsService : SettingsManager
{ {
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public bool IsAutoUpdateEnabled { get; set; } = true; public bool IsAutoUpdateEnabled { get; set; } = true;
public bool IsDarkModeEnabled { get; set; }
public bool IsTokenPersisted { get; set; } = true; public bool IsTokenPersisted { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int ParallelLimit { get; set; } = 1; public int ParallelLimit { get; set; } = 1;
public bool ShouldReuseMedia { get; set; }
public AuthToken? LastToken { get; set; } public AuthToken? LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
@@ -10,7 +10,8 @@ namespace DiscordChatExporter.Gui.Services
{ {
private readonly IUpdateManager _updateManager = new UpdateManager( private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"), new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor()); new ZipPackageExtractor()
);
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;
@@ -75,10 +75,16 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia; _settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
// If single channel - prompt file path // If single channel - prompt file path
if (IsSingleChannel) if (Channels != null && IsSingleChannel)
{ {
var channel = Channels.Single(); var channel = Channels.Single();
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before); var defaultFileName = ExportRequest.GetDefaultOutputFileName(
Guild!,
channel,
SelectedFormat,
After,
Before
);
// Filter // Filter
var ext = SelectedFormat.GetFileExtension(); var ext = SelectedFormat.GetFileExtension();
@@ -92,11 +98,24 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
OutputPath = _dialogManager.PromptDirectoryPath(); OutputPath = _dialogManager.PromptDirectoryPath();
} }
// If canceled - return
if (string.IsNullOrWhiteSpace(OutputPath)) if (string.IsNullOrWhiteSpace(OutputPath))
return; return;
Close(true); Close(true);
} }
} }
public static class ExportSetupViewModelExtensions
{
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
Guild guild, IReadOnlyList<Channel> channels)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
}
} }
@@ -8,29 +8,41 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{ {
private readonly SettingsService _settingsService; private readonly SettingsService _settingsService;
public string DateFormat
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
}
public bool IsAutoUpdateEnabled public bool IsAutoUpdateEnabled
{ {
get => _settingsService.IsAutoUpdateEnabled; get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value; set => _settingsService.IsAutoUpdateEnabled = value;
} }
public bool IsDarkModeEnabled
{
get => _settingsService.IsDarkModeEnabled;
set => _settingsService.IsDarkModeEnabled = value;
}
public bool IsTokenPersisted public bool IsTokenPersisted
{ {
get => _settingsService.IsTokenPersisted; get => _settingsService.IsTokenPersisted;
set => _settingsService.IsTokenPersisted = value; set => _settingsService.IsTokenPersisted = value;
} }
public string DateFormat
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
}
public int ParallelLimit public int ParallelLimit
{ {
get => _settingsService.ParallelLimit; get => _settingsService.ParallelLimit;
set => _settingsService.ParallelLimit = value.Clamp(1, 10); set => _settingsService.ParallelLimit = value.Clamp(1, 10);
} }
public bool ShouldReuseMedia
{
get => _settingsService.ShouldReuseMedia;
set => _settingsService.ShouldReuseMedia = value;
}
public SettingsViewModel(SettingsService settingsService) public SettingsViewModel(SettingsService settingsService)
{ {
@@ -19,13 +19,10 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen) public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
{ {
// Get the view that renders this viewmodel
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
// Set up event routing that will close the view when called from viewmodel
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs) void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
{ {
// Delegate to close the dialog and unregister event handler
void OnScreenClosed(object? o, EventArgs closeArgs) void OnScreenClosed(object? o, EventArgs closeArgs)
{ {
openArgs.Session.Close(); openArgs.Session.Close();
@@ -35,37 +32,31 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
dialogScreen.Closed += OnScreenClosed; dialogScreen.Closed += OnScreenClosed;
} }
// Show view
await DialogHost.Show(view, OnDialogOpened); await DialogHost.Show(view, OnDialogOpened);
// Return the result
return dialogScreen.DialogResult; return dialogScreen.DialogResult;
} }
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "") public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
{ {
// Create dialog
var dialog = new SaveFileDialog var dialog = new SaveFileDialog
{ {
Filter = filter, Filter = filter,
AddExtension = true, AddExtension = true,
FileName = defaultFilePath, FileName = defaultFilePath,
DefaultExt = Path.GetExtension(defaultFilePath) ?? "" DefaultExt = Path.GetExtension(defaultFilePath)
}; };
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.FileName : null; return dialog.ShowDialog() == true ? dialog.FileName : null;
} }
public string? PromptDirectoryPath(string defaultDirPath = "") public string? PromptDirectoryPath(string defaultDirPath = "")
{ {
// Create dialog
var dialog = new VistaFolderBrowserDialog var dialog = new VistaFolderBrowserDialog
{ {
SelectedPath = defaultDirPath SelectedPath = defaultDirPath
}; };
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.SelectedPath : null; return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
} }
} }
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
public void Close(T dialogResult = default) public void Close(T dialogResult = default)
{ {
DialogResult = dialogResult; DialogResult = dialogResult!;
Closed?.Invoke(this, EventArgs.Empty); Closed?.Invoke(this, EventArgs.Empty);
} }
} }
@@ -1,19 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework
{
public static class Extensions
{
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
Guild guild, IReadOnlyList<Channel> channels)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
}
}
@@ -8,7 +8,9 @@ using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions; using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Exporting; using DiscordChatExporter.Domain.Exporting;
using DiscordChatExporter.Domain.Utilities; using DiscordChatExporter.Domain.Utilities;
using DiscordChatExporter.Gui.Internal;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress; using Gress;
using MaterialDesignThemes.Wpf; using MaterialDesignThemes.Wpf;
@@ -63,14 +65,21 @@ namespace DiscordChatExporter.Gui.ViewModels
// Update busy state when progress manager changes // Update busy state when progress manager changes
ProgressManager.Bind(o => o.IsActive, ProgressManager.Bind(o => o.IsActive,
(sender, args) => IsBusy = ProgressManager.IsActive); (sender, args) => IsBusy = ProgressManager.IsActive
);
ProgressManager.Bind(o => o.IsActive, ProgressManager.Bind(o => o.IsActive,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); (sender, args) => IsProgressIndeterminate =
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
);
ProgressManager.Bind(o => o.Progress, ProgressManager.Bind(o => o.Progress,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)); (sender, args) => IsProgressIndeterminate =
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
);
} }
private async ValueTask HandleAutoUpdateAsync() private async ValueTask CheckForUpdatesAsync()
{ {
try try
{ {
@@ -108,7 +117,16 @@ namespace DiscordChatExporter.Gui.ViewModels
TokenValue = _settingsService.LastToken.Value; TokenValue = _settingsService.LastToken.Value;
} }
await HandleAutoUpdateAsync(); if (_settingsService.IsDarkModeEnabled)
{
App.SetDarkTheme();
}
else
{
App.SetLightTheme();
}
await CheckForUpdatesAsync();
} }
protected override void OnClose() protected override void OnClose()
@@ -125,6 +143,8 @@ namespace DiscordChatExporter.Gui.ViewModels
await _dialogManager.ShowDialogAsync(dialog); await _dialogManager.ShowDialogAsync(dialog);
} }
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels => public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue); !IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
@@ -178,8 +198,8 @@ namespace DiscordChatExporter.Gui.ViewModels
var exporter = new ChannelExporter(token); var exporter = new ChannelExporter(token);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count); var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0; var successfulExportCount = 0;
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple => await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
{ {
var (channel, operation) = tuple; var (channel, operation) = tuple;
@@ -195,6 +215,7 @@ namespace DiscordChatExporter.Gui.ViewModels
dialog.Before, dialog.Before,
dialog.PartitionLimit, dialog.PartitionLimit,
dialog.ShouldDownloadMedia, dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia,
_settingsService.DateFormat _settingsService.DateFormat
); );
@@ -48,12 +48,13 @@
Margin="8,0,0,0" Margin="8,0,0,0"
VerticalAlignment="Center" VerticalAlignment="Center"
FontSize="19" FontSize="19"
FontWeight="Light"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"> Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" /> <Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
<Run Text="/" /> <Run Text="/" />
<Run <Run
Foreground="{DynamicResource PrimaryTextBrush}" FontWeight="SemiBold"
Text="{Binding Channels[0].Name, Mode=OneWay}" Text="{Binding Channels[0].Name, Mode=OneWay}"
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" /> ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock> </TextBlock>
@@ -94,7 +95,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent after this date will be exported" /> ToolTip="Only include messages sent after this date" />
<DatePicker <DatePicker
Grid.Row="0" Grid.Row="0"
Grid.Column="1" Grid.Column="1"
@@ -103,7 +104,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}" SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent before this date will be exported" /> ToolTip="Only include messages sent before this date" />
<materialDesign:TimePicker <materialDesign:TimePicker
Grid.Row="1" Grid.Row="1"
Grid.Column="0" Grid.Column="0"
@@ -112,7 +113,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsAfterDateSet}" IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}" SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent after this time will be exported" /> ToolTip="Only include messages sent after this time" />
<materialDesign:TimePicker <materialDesign:TimePicker
Grid.Row="1" Grid.Row="1"
Grid.Column="1" Grid.Column="1"
@@ -121,7 +122,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsBeforeDateSet}" IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}" SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent before this time will be exported" /> ToolTip="Only include messages sent before this time" />
</Grid> </Grid>
<!-- Partitioning --> <!-- Partitioning -->
@@ -130,10 +131,10 @@
materialDesign:HintAssist.Hint="Messages per partition" materialDesign:HintAssist.Hint="Messages per partition"
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimit, TargetNullValue=''}" Text="{Binding PartitionLimit, TargetNullValue=''}"
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" /> ToolTip="Split output into partitions limited to this number of messages" />
<!-- Download media --> <!-- Download media -->
<Grid Margin="16,16" ToolTip="If this is set, the export will include additional files such as user avatars, attached files, embedded images, etc"> <Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -142,7 +143,7 @@
<TextBlock <TextBlock
Grid.Column="0" Grid.Column="0"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="Download referenced media content" /> Text="Download media" />
<ToggleButton <ToggleButton
Grid.Column="1" Grid.Column="1"
HorizontalAlignment="Right" HorizontalAlignment="Right"
@@ -163,18 +164,13 @@
<ToggleButton <ToggleButton
x:Name="AdvancedSectionToggleButton" x:Name="AdvancedSectionToggleButton"
Grid.Column="0" Grid.Column="0"
Margin="8" Width="24"
Height="24"
Margin="12"
Cursor="Hand" Cursor="Hand"
Loaded="AdvancedSectionToggleButton_OnLoaded" IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
Style="{DynamicResource MaterialDesignFlatToggleButton}" Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
ToolTip="Show advanced options"> ToolTip="Toggle advanced options" />
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Menu" />
</ToggleButton.Content>
</ToggleButton>
<Button <Button
Grid.Column="2" Grid.Column="2"
@@ -1,7 +1,4 @@
using System.Windows; namespace DiscordChatExporter.Gui.Views.Dialogs
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.Views.Dialogs
{ {
public partial class ExportSetupView public partial class ExportSetupView
{ {
@@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
{ {
InitializeComponent(); InitializeComponent();
} }
private void AdvancedSectionToggleButton_OnLoaded(object sender, RoutedEventArgs e)
{
if (DataContext is ExportSetupViewModel vm)
AdvancedSectionToggleButton.IsChecked = vm.IsAdvancedSectionDisplayedByDefault;
}
} }
} }
@@ -7,7 +7,7 @@
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet" xmlns:s="https://github.com/canton7/Stylet"
Width="300" Width="310"
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}" d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
Style="{DynamicResource MaterialDesignRoot}" Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d"> mc:Ignorable="d">
@@ -15,17 +15,8 @@
<TextBlock <TextBlock
Margin="16" Margin="16"
FontSize="17" FontSize="17"
Foreground="{DynamicResource PrimaryTextBrush}"
Text="Settings" /> Text="Settings" />
<!-- Date format -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}"
ToolTip="Format used when rendering dates (refer to .NET date format)" />
<!-- Auto-updates --> <!-- Auto-updates -->
<DockPanel <DockPanel
Background="Transparent" Background="Transparent"
@@ -41,6 +32,24 @@
IsChecked="{Binding IsAutoUpdateEnabled}" /> IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel> </DockPanel>
<!-- Dark mode -->
<DockPanel
Background="Transparent"
LastChildFill="False"
ToolTip="Use darker colors in the UI">
<TextBlock
Margin="16,8"
DockPanel.Dock="Left"
Text="Dark mode" />
<ToggleButton
x:Name="DarkModeToggleButton"
Margin="16,8"
Checked="DarkModeToggleButton_Checked"
DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" />
</DockPanel>
<!-- Persist token --> <!-- Persist token -->
<DockPanel <DockPanel
Background="Transparent" Background="Transparent"
@@ -56,14 +65,37 @@
IsChecked="{Binding IsTokenPersisted}" /> IsChecked="{Binding IsTokenPersisted}" />
</DockPanel> </DockPanel>
<!-- Reuse media -->
<DockPanel
Background="Transparent"
LastChildFill="False"
ToolTip="Reuse already existing media content to skip redundant downloads">
<TextBlock
Margin="16,8"
DockPanel.Dock="Left"
Text="Reuse downloaded media" />
<ToggleButton
Margin="16,8"
DockPanel.Dock="Right"
IsChecked="{Binding ShouldReuseMedia}" />
</DockPanel>
<!-- Date format -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Date format"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding DateFormat}"
ToolTip="Format used when writing dates (uses .NET date formatting rules)" />
<!-- Parallel limit --> <!-- Parallel limit -->
<StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time"> <StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time">
<TextBlock Margin="16,8"> <TextBlock Margin="16,8,16,4">
<Run Text="Parallel limit:" /> <Run Text="Parallel limit:" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding ParallelLimit, Mode=OneWay}" /> <Run Text="{Binding ParallelLimit, Mode=OneWay}" />
</TextBlock> </TextBlock>
<Slider <Slider
Margin="16,8" Margin="16,4,16,8"
IsSnapToTickEnabled="True" IsSnapToTickEnabled="True"
Maximum="10" Maximum="10"
Minimum="1" Minimum="1"
@@ -1,4 +1,6 @@
namespace DiscordChatExporter.Gui.Views.Dialogs using System.Windows;
namespace DiscordChatExporter.Gui.Views.Dialogs
{ {
public partial class SettingsView public partial class SettingsView
{ {
@@ -6,5 +8,11 @@
{ {
InitializeComponent(); InitializeComponent();
} }
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
App.SetDarkTheme();
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
App.SetLightTheme();
} }
} }
+52 -28
View File
@@ -46,10 +46,7 @@
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Toolbar --> <!-- Toolbar -->
<Grid <Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
Grid.Row="0"
Background="{DynamicResource PrimaryHueMidBrush}"
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -124,8 +121,12 @@
Margin="6" Margin="6"
Padding="4" Padding="4"
Command="{s:Action ShowSettings}" Command="{s:Action ShowSettings}"
Style="{DynamicResource MaterialDesignFlatDarkButton}" Foreground="{DynamicResource MaterialDesignDarkForeground}"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Settings"> ToolTip="Settings">
<Button.Resources>
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
</Button.Resources>
<materialDesign:PackIcon <materialDesign:PackIcon
Width="24" Width="24"
Height="24" Height="24"
@@ -136,12 +137,20 @@
<!-- Progress bar --> <!-- Progress bar -->
<ProgressBar <ProgressBar
Grid.Row="1" Grid.Row="1"
Background="{DynamicResource PrimaryHueMidBrush}" Background="{DynamicResource MaterialDesignDarkBackground}"
IsIndeterminate="{Binding IsProgressIndeterminate}" IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding ProgressManager.Progress, Mode=OneWay}" /> Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
<!-- Content --> <!-- Content -->
<Grid Grid.Row="2" IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}"> <Grid
Grid.Row="2"
Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="Light" />
</Style>
</Grid.Resources>
<!-- Placeholder / usage instructions --> <!-- Placeholder / usage instructions -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token --> <!-- For user token -->
@@ -151,25 +160,25 @@
<Run Text="1. Open Discord" /> <Run Text="1. Open Discord" />
<LineBreak /> <LineBreak />
<Run Text="2. Press" /> <Run Text="2. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" /> <Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" /> <Run Text="to show developer tools" />
<LineBreak /> <LineBreak />
<Run Text="3. Navigate to the" /> <Run Text="3. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" /> <Run FontWeight="SemiBold" Text="Application" />
<Run Text="tab" /> <Run Text="tab" />
<LineBreak /> <LineBreak />
<Run Text="4. Select" /> <Run Text="4. Select" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" /> <Run FontWeight="SemiBold" Text="Local Storage" />
<Run Text="&gt;" /> <Run Text="&gt;" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discord.com" /> <Run FontWeight="SemiBold" Text="https://discord.com" />
<Run Text="on the left" /> <Run Text="on the left" />
<LineBreak /> <LineBreak />
<Run Text="5. Press" /> <Run Text="5. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" /> <Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" /> <Run Text="to reload" />
<LineBreak /> <LineBreak />
<Run Text="6. Find" /> <Run Text="6. Find" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" /> <Run FontWeight="SemiBold" Text="token" />
<Run Text="at the bottom and copy the value" /> <Run Text="at the bottom and copy the value" />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> <TextBlock Margin="0,24,0,0" FontSize="14">
@@ -177,41 +186,55 @@
<LineBreak /> <LineBreak />
<Run Text="To authorize using bot token instead, click" /> <Run Text="To authorize using bot token instead, click" />
<InlineUIContainer> <InlineUIContainer>
<materialDesign:PackIcon Margin="1,0,0,-3" Kind="Account" /> <materialDesign:PackIcon
Margin="1,0,0,-3"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer> </InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> <TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" /> <Run Text="For more information, check out the" />
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." /> <Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
<!-- For bot token --> <!-- For bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"> <StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock FontSize="18" Text="Please provide your bot token to authorize" /> <TextBlock
<TextBlock Margin="8,8,0,0" FontSize="14"> FontSize="18"
FontWeight="Light"
Text="Please provide your bot token to authorize" />
<TextBlock
Margin="8,8,0,0"
FontSize="14"
FontWeight="Light">
<Run Text="1. Open Discord developer portal" /> <Run Text="1. Open Discord developer portal" />
<LineBreak /> <LineBreak />
<Run Text="2. Open your application's settings" /> <Run Text="2. Open your application's settings" />
<LineBreak /> <LineBreak />
<Run Text="3. Navigate to the" /> <Run Text="3. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" /> <Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" /> <Run Text="section on the left" />
<LineBreak /> <LineBreak />
<Run Text="4. Under" /> <Run Text="4. Under" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" /> <Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" /> <Run Text="click" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" /> <Run FontWeight="SemiBold" Text="Copy" />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> <TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="To authorize using user token instead, click" /> <Run Text="To authorize using user token instead, click" />
<InlineUIContainer> <InlineUIContainer>
<materialDesign:PackIcon Margin="1,0,0,-1" Kind="Robot" /> <materialDesign:PackIcon
Margin="1,0,0,-1"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer> </InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock> </TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14"> <TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" /> <Run Text="For more information, check out the" />
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." /> <Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock> </TextBlock>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -226,7 +249,7 @@
<!-- Guilds --> <!-- Guilds -->
<Border <Border
Grid.Column="0" Grid.Column="0"
BorderBrush="{DynamicResource DividerBrush}" BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,1,0"> BorderThickness="0,0,1,0">
<ListBox <ListBox
ItemsSource="{Binding AvailableGuilds}" ItemsSource="{Binding AvailableGuilds}"
@@ -245,13 +268,15 @@
Width="48" Width="48"
Height="48" Height="48"
Margin="12,4,12,4" Margin="12,4,12,4"
Fill="{DynamicResource DividerBrush}" /> Fill="{DynamicResource MaterialDesignDivider}" />
<!-- Guild icon --> <!-- Guild icon -->
<Ellipse <Ellipse
Width="48" Width="48"
Height="48" Height="48"
Margin="12,4,12,4"> Margin="12,4,12,4"
Stroke="{DynamicResource MaterialDesignDivider}"
StrokeThickness="1">
<Ellipse.Fill> <Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" /> <ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill> </Ellipse.Fill>
@@ -284,8 +309,8 @@
Margin="0" Margin="0"
Padding="0" Padding="0"
Background="Transparent" Background="Transparent"
BorderBrush="{DynamicResource DividerBrush}" BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1,0,0" BorderThickness="0,0,0,1"
Header="{Binding Name}" Header="{Binding Name}"
IsExpanded="False"> IsExpanded="False">
<ItemsPresenter /> <ItemsPresenter />
@@ -322,7 +347,6 @@
Margin="3,8,8,8" Margin="3,8,8,8"
VerticalAlignment="Center" VerticalAlignment="Center"
FontSize="14" FontSize="14"
Foreground="{DynamicResource PrimaryTextBrush}"
Text="{Binding Name, Mode=OneWay}" /> Text="{Binding Name, Mode=OneWay}" />
<!-- Is selected checkmark --> <!-- Is selected checkmark -->
+1 -10
View File
@@ -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 -1
View File
@@ -2,11 +2,12 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework> <TargetFramework>netcoreapp3.1</TargetFramework>
<Version>2.22</Version> <Version>2.25</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>
+2 -2
View File
@@ -1,5 +1,5 @@
# Build # Build
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build FROM mcr.microsoft.com/dotnet/sdk:3.1 AS build
WORKDIR /src WORKDIR /src
COPY favicon.ico ./ COPY favicon.ico ./
@@ -10,7 +10,7 @@ COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
RUN dotnet publish DiscordChatExporter.Cli -o DiscordChatExporter.Cli/publish -c Release RUN dotnet publish DiscordChatExporter.Cli -o DiscordChatExporter.Cli/publish -c Release
# Run # Run
FROM mcr.microsoft.com/dotnet/core/runtime:3.1 AS run FROM mcr.microsoft.com/dotnet/runtime:3.1 AS run
WORKDIR /app WORKDIR /app
COPY --from=build /src/DiscordChatExporter.Cli/publish ./ COPY --from=build /src/DiscordChatExporter.Cli/publish ./
+9 -6
View File
@@ -6,21 +6,24 @@
[![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate) [![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate)
[![Extra Services](https://img.shields.io/badge/extra%20services-xs:code-blue.svg)](https://xscode.com/Tyrrrz/DiscordChatExporter) [![Extra Services](https://img.shields.io/badge/extra%20services-xs:code-blue.svg)](https://xscode.com/Tyrrrz/DiscordChatExporter)
**Project status: active**.
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 or need help? Check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki).
## 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 v3.1 runtime in order to run:
- .NET Core runtime for Windows: [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x64-installer) | [x86](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x86-installer) - .NET Runtime for Windows: [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x64-installer) | [x86](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x86-installer)
- .NET Core runtime for macOS (for CLI version only): [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-3.1.0-macos-x64-installer) - .NET Runtime for macOS (for CLI version only): [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-3.1.0-macos-x64-installer)
- .NET Core runtime for Linux (for CLI version only): [Ubuntu/Debian](https://docs.microsoft.com/en-us/dotnet/core/install/linux-package-manager-ubuntu-1904#install-the-net-core-runtime) - .NET Runtime for Linux (for CLI version only): [Ubuntu/Debian](https://docs.microsoft.com/en-us/dotnet/core/install/linux-package-manager-ubuntu-1904#install-the-net-core-runtime)
## Features ## Features
@@ -37,4 +40,4 @@ Note: This application requires .NET Core runtime in order to run:
## Screenshots ## Screenshots
![channel list](.screenshots/list.png) ![channel list](.screenshots/list.png)
![rendered output](.screenshots/output.png) ![rendered output](.screenshots/output.png)