Compare commits

..

56 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
Alexey Golub 1495ed9b90 Update version 2020-08-12 19:21:24 +03:00
Alexey Golub be66c74c08 Update nuget packages 2020-08-12 19:11:18 +03:00
Alexey Golub 6f54c09d96 [GUI] Refactor sorting 2020-08-12 19:09:12 +03:00
Alexey Golub 45926b0990 Refactor last commit 2020-08-07 21:41:10 +03:00
CarJem Generations 25d29dc079 [GUI] Add controls to configure export time limits in addition to dates (#355) 2020-08-07 21:06:49 +03:00
Alexey Golub efde9931c9 Refactor & optimize changes in last commit 2020-08-07 21:00:43 +03:00
CarJem Generations 2c3b461d49 [GUI] Make categories collapsible in channel list (#354) 2020-08-07 19:42:59 +03:00
CarJem Generations b405052fd6 [HTML] Workaround for Tupperbox nicknames (#352) 2020-08-05 21:58:17 +03:00
Alexey Golub da6d903f36 Update version 2020-07-30 16:03:27 +03:00
Alexey Golub e1b1755fba Don't escape media URL on non-HTML formats 2020-07-30 16:02:00 +03:00
Alexey Golub 1ff026eba6 [GUI] Trim spaces in token input 2020-07-29 20:58:49 +03:00
Alexey Golub 1fe4ecb3af Truncate long file names in MediaDownloader
Fixes #344
2020-07-29 20:58:38 +03:00
Alexey Golub 47a1518cd9 Update nuget packages 2020-07-29 20:49:08 +03:00
Alexey Golub d78c10ebb7 Remove default cap on MakeUniqueFilePath 2020-07-29 20:47:58 +03:00
Alexey Golub 3db29b520c Switch from RazorLight to MiniRazor 2020-07-29 20:08:51 +03:00
Alexey Golub 752003abc3 Fix media downloading 2020-07-28 23:07:31 +03:00
Alexey Golub e26a0660d1 Use Razor instead of Scriban 2020-07-28 22:43:45 +03:00
Alexey Golub 7dfc3b2723 Add link to extra services in readme 2020-07-26 15:54:30 +03:00
Alexey Golub 06c33373de Sanitize file names when exporting media
Fixes #332
2020-07-25 21:17:05 +03:00
72 changed files with 1546 additions and 896 deletions
+47 -47
View File
@@ -3,64 +3,64 @@ name: CD
on:
push:
tags:
- '*'
- "*"
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Checkout
uses: actions/checkout@v2.3.3
- name: Install .NET Core
uses: actions/setup-dotnet@v1.4.0
with:
dotnet-version: 3.1.100
- name: Install .NET
uses: actions/setup-dotnet@v1.7.2
with:
dotnet-version: 3.1.100
- name: Build & publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
- name: Build & publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
- name: Build & publish (GUI)
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
- name: Build & publish (GUI)
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
- name: Pack (CLI)
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
shell: pwsh
- name: Pack (CLI)
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
shell: pwsh
- name: Pack (GUI)
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
shell: pwsh
- name: Pack (GUI)
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
shell: pwsh
- name: Create release
id: create_release
uses: actions/create-release@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: |
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
draft: false
prerelease: false
- name: Create release
id: create_release
uses: actions/create-release@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
body: |
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
draft: false
prerelease: false
- name: Upload release asset (CLI)
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
asset_name: DiscordChatExporter.CLI.zip
asset_content_type: application/zip
- name: Upload release asset (CLI)
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
asset_name: DiscordChatExporter.CLI.zip
asset_content_type: application/zip
- name: Upload release asset (GUI)
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
asset_name: DiscordChatExporter.zip
asset_content_type: application/zip
- name: Upload release asset (GUI)
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
asset_name: DiscordChatExporter.zip
asset_content_type: application/zip
+20 -20
View File
@@ -7,28 +7,28 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Checkout
uses: actions/checkout@v2.3.3
- name: Install .NET Core
uses: actions/setup-dotnet@v1.4.0
with:
dotnet-version: 3.1.100
- name: Install .NET
uses: actions/setup-dotnet@v1.7.2
with:
dotnet-version: 3.1.100
- name: Build & publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
- name: Build & publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
- name: Build & publish (GUI)
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
- name: Build & publish (GUI)
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
- name: Upload build artifacts (CLI)
uses: actions/upload-artifact@v1
with:
name: DiscordChatExporter.CLI
path: DiscordChatExporter.Cli/bin/Publish/
- name: Upload build artifacts (CLI)
uses: actions/upload-artifact@v1
with:
name: DiscordChatExporter.CLI
path: DiscordChatExporter.Cli/bin/Publish/
- name: Upload build artifact (GUI)
uses: actions/upload-artifact@v1
with:
name: DiscordChatExporter
path: DiscordChatExporter.Gui/bin/Publish/
- name: Upload build artifact (GUI)
uses: actions/upload-artifact@v1
with:
name: DiscordChatExporter
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

+39
View File
@@ -1,3 +1,42 @@
### 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)
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
- [GUI] Improved exporting options by adding a set of controls that can be used to limit the date range of the export down to minutes. Previously it was only possible to configure the date range without the time component. (Thanks [@CarJem Generations](https://github.com/CarJem))
- [HTML] Fixed an issue where the export didn't reflect changes in nicknames between messages sent by bots. This affected chat logs that contained interactions with the Tupperbox bot. (Thanks [@CarJem Generations](https://github.com/CarJem))
- [CLI] Fixed an issue where the application crashed if there were two environment variables defined that had the same name but in different case.
### v2.21.2 (30-Jul-2020)
- [GUI] When copy-pasting token, any surrounding spaces are now discarded, in addition to double quotes.
- [HTML] Changed underlying templating engine to provide higher performance and better error messages. New templating engine adds a small cold start, but any export after the first should be faster.
- Changed naming schema for downloaded media so that it follows `<original filename> (nn)` format rather than `<original filename>-salt`.
- [HTML] Fixed an issue where downloaded media was sometimes inaccessible due to reserved characters appearing in the URL.
- Fixed an issue where the application crashed when trying to download media with a file name that exceeds system's maximum allowed length.
- Fixed an issue where the application crashed when trying to download media with illegal characters in the file name.
### v2.21.1 (19-Jul-2020)
- Fixed an issue where the export crashed if an embedded image has been deleted. Such media files are now ignored.
@@ -3,6 +3,7 @@ using System.IO;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Utilities;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting;
@@ -11,25 +12,36 @@ namespace DiscordChatExporter.Cli.Commands.Base
{
public abstract class ExportCommandBase : TokenCommandBase
{
[CommandOption("output", 'o', Description = "Output file or directory path.")]
[CommandOption("output", 'o',
Description = "Output file or directory path.")]
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Output file format.")]
[CommandOption("format", 'f',
Description = "Export format.")]
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
[CommandOption("after", Description = "Limit to messages sent after this date.")]
[CommandOption("after",
Description = "Only include messages sent after this date.")]
public DateTimeOffset? After { get; set; }
[CommandOption("before", Description = "Limit to messages sent before this date.")]
[CommandOption("before",
Description = "Only include messages sent before this date.")]
public DateTimeOffset? Before { get; set; }
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
[CommandOption("partition", 'p',
Description = "Split output into partitions limited to this number of messages.")]
public int? PartitionLimit { get; set; }
[CommandOption("media", Description = "Download referenced media content.")]
[CommandOption("media",
Description = "Download referenced media content.")]
public bool ShouldDownloadMedia { get; set; }
[CommandOption("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";
protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient());
@@ -48,6 +60,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
Before,
PartitionLimit,
ShouldDownloadMedia,
ShouldReuseMedia,
DateFormat
);
@@ -68,5 +81,17 @@ namespace DiscordChatExporter.Cli.Commands.Base
var channel = await GetDiscordClient().GetChannelAsync(channelId);
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.Threading;
using System.Threading.Tasks;
@@ -16,21 +17,23 @@ namespace DiscordChatExporter.Cli.Commands.Base
{
public abstract class ExportMultipleCommandBase : ExportCommandBase
{
[CommandOption("parallel", Description = "Export this number of channels in parallel.")]
[CommandOption("parallel",
Description = "Limits how many channels can be exported in parallel.")]
public int ParallelLimit { get; set; } = 1;
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
{
// HACK: this uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
// This uses a different route from ExportCommandBase.ExportAsync() because it runs
// in parallel and needs another way to report progress to console.
console.Output.Write($"Exporting {channels.Count} channels... ");
var progress = console.CreateProgressTicker();
var operations = progress.Wrap().CreateOperations(channels.Count);
var errors = new List<string>();
var successfulExportCount = 0;
var errors = new ConcurrentBag<string>();
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
{
var (channel, operation) = tuple;
@@ -48,6 +51,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
Before,
PartitionLimit,
ShouldDownloadMedia,
ShouldReuseMedia,
DateFormat
);
@@ -7,15 +7,22 @@ namespace DiscordChatExporter.Cli.Commands.Base
{
public abstract class TokenCommandBase : ICommand
{
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN",
[CommandOption("token", 't', IsRequired = true,
EnvironmentVariableName = "DISCORD_TOKEN",
Description = "Authorization token.")]
public string TokenValue { get; set; } = "";
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT",
Description = "Whether this authorization token belongs to a bot.")]
[CommandOption("bot", 'b',
EnvironmentVariableName = "DISCORD_TOKEN_BOT",
Description = "Authorize as a bot.")]
public bool IsBotToken { get; set; }
protected AuthToken GetAuthToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
protected AuthToken GetAuthToken() => new AuthToken(
IsBotToken
? AuthTokenType.Bot
: AuthTokenType.User,
TokenValue
);
protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken());
@@ -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.")]
public class ExportChannelCommand : ExportCommandBase
{
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
[CommandOption("channel", 'c', IsRequired = true,
Description = "Channel ID.")]
public string ChannelId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console) => await ExportAsync(console, ChannelId);
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
await ExportAsync(console, ChannelId);
}
}
}
@@ -12,8 +12,8 @@ namespace DiscordChatExporter.Cli.Commands
{
public override async ValueTask ExecuteAsync(IConsole console)
{
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
await ExportMultipleAsync(console, dmChannels);
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
await ExportMultipleAsync(console, channels);
}
}
}
@@ -9,13 +9,14 @@ namespace DiscordChatExporter.Cli.Commands
[Command("exportguild", Description = "Export all channels within specified guild.")]
public class ExportGuildCommand : ExportMultipleCommandBase
{
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
[CommandOption("guild", 'g', IsRequired = true,
Description = "Guild ID.")]
public string GuildId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console)
{
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
await ExportMultipleAsync(console, guildChannels);
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
await ExportMultipleAsync(console, channels);
}
}
}
@@ -7,17 +7,18 @@ using DiscordChatExporter.Domain.Utilities;
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
{
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
[CommandOption("guild", 'g', IsRequired = true,
Description = "Guild ID.")]
public string GuildId { get; set; } = "";
public override async ValueTask ExecuteAsync(IConsole console)
{
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}");
}
}
@@ -13,9 +13,9 @@ namespace DiscordChatExporter.Cli.Commands
{
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}");
}
}
@@ -10,7 +10,9 @@ namespace DiscordChatExporter.Cli.Commands
{
public ValueTask ExecuteAsync(IConsole console)
{
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get user token:"));
console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get user token:")
);
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 3. Navigate to the Application tab");
@@ -20,14 +22,18 @@ namespace DiscordChatExporter.Cli.Commands
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get bot token:"));
console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get bot token:")
);
console.Output.WriteLine(" 1. Go to Discord developer portal");
console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy");
console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get guild ID or guild channel ID:"));
console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get guild ID or guild channel ID:")
);
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Appearance section");
@@ -35,7 +41,9 @@ namespace DiscordChatExporter.Cli.Commands
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get direct message channel ID:"));
console.WithForegroundColor(ConsoleColor.White, () =>
console.Output.WriteLine("To get direct message channel ID:")
);
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open the desired direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
@@ -44,8 +52,12 @@ namespace DiscordChatExporter.Cli.Commands
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
console.Output.WriteLine();
console.Output.WriteLine("For more information, check out the wiki:");
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
console.WithForegroundColor(ConsoleColor.White,
() => console.Output.WriteLine("For more information, check out the wiki:")
);
console.WithForegroundColor(ConsoleColor.Blue,
() => console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki")
);
return default;
}
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="1.2.0" />
<PackageReference Include="CliFx" Version="1.5.0" />
<PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
</ItemGroup>
@@ -9,50 +9,33 @@ using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exceptions;
using DiscordChatExporter.Domain.Internal;
using DiscordChatExporter.Domain.Internal.Extensions;
using Polly;
namespace DiscordChatExporter.Domain.Discord
{
public class DiscordClient
{
private readonly HttpClient _httpClient;
private readonly AuthToken _token;
private readonly HttpClient _httpClient = Singleton.HttpClient;
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
public DiscordClient(AuthToken token)
public DiscordClient(HttpClient httpClient, AuthToken token)
{
_httpClient = httpClient;
_token = token;
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
// finally wait the specified amount of time, based on how many requests have failed in a row.
_httpRequestPolicy = Policy
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
.WaitAndRetryAsync(6,
(i, result, ctx) =>
{
if (i <= 3)
return TimeSpan.FromSeconds(2 * i);
if (i <= 5)
return TimeSpan.FromSeconds(5 * i);
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
},
(response, timespan, retryCount, context) => Task.CompletedTask
);
}
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = _token.GetAuthorizationHeader();
public DiscordClient(AuthToken token)
: this(Http.Client, token) {}
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)
{
@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Domain.Discord.Models.Common;
using DiscordChatExporter.Domain.Internal.Extensions;
@@ -20,11 +20,14 @@ namespace DiscordChatExporter.Domain.Discord.Models
public int? Height { get; }
public bool IsImage =>
ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsVideo => VideoFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsAudio => AudioFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsSpoiler =>
IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
(IsImage || IsVideo || IsAudio) && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
public FileSize FileSize { get; }
@@ -43,7 +46,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
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)
{
@@ -33,6 +33,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
public DateTimeOffset? EditedTimestamp { get; }
public DateTimeOffset? CallEndedTimestamp { get; }
public bool IsPinned { get; }
public string Content { get; }
@@ -51,6 +53,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
User author,
DateTimeOffset timestamp,
DateTimeOffset? editedTimestamp,
DateTimeOffset? callEndedTimestamp,
bool isPinned,
string content,
IReadOnlyList<Attachment> attachments,
@@ -63,6 +66,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
Author = author;
Timestamp = timestamp;
EditedTimestamp = editedTimestamp;
CallEndedTimestamp = callEndedTimestamp;
IsPinned = isPinned;
Content = content;
Attachments = attachments;
@@ -82,6 +86,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
var author = json.GetProperty("author").Pipe(User.Parse);
var timestamp = json.GetProperty("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 isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
@@ -89,7 +94,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
{
MessageType.RecipientAdd => "Added 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.ChannelIconChange => "Changed the channel icon.",
MessageType.ChannelPinnedMessage => "Pinned a message.",
@@ -119,6 +125,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
author,
timestamp,
editedTimestamp,
callEndedTimestamp,
isPinned,
content,
attachments,
@@ -2,17 +2,18 @@
<Import Project="../DiscordChatExporter.props" />
<ItemGroup>
<PackageReference Include="MiniRazor" Version="1.1.0" />
<PackageReference Include="Polly" Version="7.2.1" />
<PackageReference Include="Scriban" Version="2.1.2" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Exporting\Writers\Html\Core.css" />
<EmbeddedResource Include="Exporting\Writers\Html\Dark.css" />
<EmbeddedResource Include="Exporting\Writers\Html\LayoutTemplate.html" />
<EmbeddedResource Include="Exporting\Writers\Html\Light.css" />
<EmbeddedResource Include="Exporting\Writers\Html\MessageGroupTemplate.html" />
<EmbeddedResource Include="Exporting\Writers\Html\PreambleTemplate.cshtml" />
<EmbeddedResource Include="Exporting\Writers\Html\PostambleTemplate.cshtml" />
<EmbeddedResource Include="Exporting\Writers\Html\MessageGroupTemplate.cshtml" />
</ItemGroup>
</Project>
</Project>
@@ -1,10 +1,13 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Internal.Extensions;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Exporting
{
@@ -31,9 +34,16 @@ namespace DiscordChatExporter.Domain.Exporting
Channels = channels;
Roles = roles;
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath);
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
}
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) =>
Members.FirstOrDefault(m => m.Id == id);
@@ -43,9 +53,9 @@ namespace DiscordChatExporter.Domain.Exporting
public Role? TryGetRole(string id) =>
Roles.FirstOrDefault(r => r.Id == id);
public Color? TryGetUserColor(User user)
public Color? TryGetUserColor(string id)
{
var member = TryGetMember(user.Id);
var member = TryGetMember(id);
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
return roles?
@@ -55,7 +65,6 @@ namespace DiscordChatExporter.Domain.Exporting
.FirstOrDefault();
}
// HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter
public async ValueTask<string> ResolveMediaUrlAsync(string url)
{
if (!Request.ShouldDownloadMedia)
@@ -63,13 +72,32 @@ namespace DiscordChatExporter.Domain.Exporting
try
{
var filePath = await _mediaDownloader.DownloadAsync(url).ConfigureAwait(false);
var filePath = await _mediaDownloader.DownloadAsync(url);
// Return relative path so that the output files can be copied around without breaking
return Path.GetRelativePath(Request.OutputBaseDirPath, filePath);
// We want relative path so that the output files can be copied around without breaking.
// 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;
// HACK: for HTML, we need to format the URL properly
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
{
// Need to escape each path segment while keeping the directory separators intact
return relativeFilePath
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Select(Uri.EscapeDataString)
.JoinToString(Path.AltDirectorySeparatorChar.ToString());
}
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
return url;
}
@@ -2,6 +2,7 @@
using System.IO;
using System.Text;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Internal;
namespace DiscordChatExporter.Domain.Exporting
{
@@ -29,6 +30,8 @@ namespace DiscordChatExporter.Domain.Exporting
public bool ShouldDownloadMedia { get; }
public bool ShouldReuseMedia { get; }
public string DateFormat { get; }
public ExportRequest(
@@ -40,6 +43,7 @@ namespace DiscordChatExporter.Domain.Exporting
DateTimeOffset? before,
int? partitionLimit,
bool shouldDownloadMedia,
bool shouldReuseMedia,
string dateFormat)
{
Guild = guild;
@@ -50,6 +54,7 @@ namespace DiscordChatExporter.Domain.Exporting
Before = before;
PartitionLimit = partitionLimit;
ShouldDownloadMedia = shouldDownloadMedia;
ShouldReuseMedia = shouldReuseMedia;
DateFormat = dateFormat;
OutputBaseFilePath = GetOutputBaseFilePath(
@@ -127,8 +132,7 @@ namespace DiscordChatExporter.Domain.Exporting
buffer.Append($".{format.GetFileExtension()}");
// Replace invalid chars
foreach (var invalidChar in Path.GetInvalidFileNameChars())
buffer.Replace(invalidChar, '_');
PathEx.EscapePath(buffer);
return buffer.ToString();
}
@@ -2,6 +2,8 @@
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Internal;
@@ -9,44 +11,76 @@ using DiscordChatExporter.Domain.Internal.Extensions;
namespace DiscordChatExporter.Domain.Exporting
{
internal class MediaDownloader
internal partial class MediaDownloader
{
private readonly HttpClient _httpClient = Singleton.HttpClient;
private readonly HttpClient _httpClient;
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;
_reuseMedia = reuseMedia;
}
private string GetRandomSuffix() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 8);
public MediaDownloader(string workingDirPath, bool reuseMedia)
: this(Http.Client, workingDirPath, reuseMedia) {}
private string GetFileNameFromUrl(string url)
{
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
if (string.IsNullOrWhiteSpace(originalFileName))
return GetRandomSuffix();
return $"{Path.GetFileNameWithoutExtension(originalFileName)}-{GetRandomSuffix()}{Path.GetExtension(originalFileName)}";
}
// HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter
public async ValueTask<string> DownloadAsync(string url)
{
if (_pathMap.TryGetValue(url, out var cachedFilePath))
if (_pathCache.TryGetValue(url, out var cachedFilePath))
return cachedFilePath;
var fileName = GetFileNameFromUrl(url);
var filePath = Path.Combine(_workingDirPath, fileName);
// Reuse existing files if we're allowed to
if (_reuseMedia && File.Exists(filePath))
return _pathCache[url] = filePath;
// Download it
Directory.CreateDirectory(_workingDirPath);
await Http.ExceptionPolicy.ExecuteAsync(async () =>
{
// This catches IOExceptions which is dangerous as we're working also with files
await _httpClient.DownloadAsync(url, filePath);
});
await _httpClient.DownloadAsync(url, filePath).ConfigureAwait(false);
return _pathMap[url] = filePath;
return _pathCache[url] = filePath;
}
}
}
internal partial class MediaDownloader
{
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)
{
var urlHash = GetUrlHash(url);
// Try to extract file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
// If it's not there, just use the URL hash as the file name
if (string.IsNullOrWhiteSpace(fileName))
return urlHash;
// Otherwise, use the original file name but inject the hash in the middle
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
var fileExtension = Path.GetExtension(fileName);
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
}
}
}
@@ -72,7 +72,9 @@ namespace DiscordChatExporter.Domain.Exporting
internal partial class MessageExporter
{
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
private static string GetPartitionFilePath(
string baseFilePath,
int partitionIndex)
{
// First partition - don't change file name
if (partitionIndex <= 0)
@@ -82,16 +84,17 @@ namespace DiscordChatExporter.Domain.Exporting
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
var fileExt = Path.GetExtension(baseFilePath);
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
// Generate new path
var dirPath = Path.GetDirectoryName(baseFilePath);
if (!string.IsNullOrWhiteSpace(dirPath))
return Path.Combine(dirPath, fileName);
return fileName;
return !string.IsNullOrWhiteSpace(dirPath)
? Path.Combine(dirPath, fileName)
: fileName;
}
private static MessageWriter CreateMessageWriter(string filePath, ExportFormat format, ExportContext context)
private static MessageWriter CreateMessageWriter(
string filePath,
ExportFormat format,
ExportContext context)
{
// Stream will be disposed by the underlying writer
var stream = File.Create(filePath);
@@ -67,7 +67,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
await _writer.WriteAsync(',');
// Message timestamp
await _writer.WriteAsync(CsvEncode(message.Timestamp.ToLocalString(Context.Request.DateFormat)));
await _writer.WriteAsync(CsvEncode(Context.FormatDate(message.Timestamp)));
await _writer.WriteAsync(',');
// Message content
@@ -2,31 +2,31 @@
@font-face {
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-face {
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-face {
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-face {
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-face {
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;
}
@@ -49,13 +49,18 @@ img {
.markdown {
max-width: 100%;
white-space: pre-wrap;
line-height: 1.3;
overflow-wrap: break-word;
}
.preserve-whitespace {
white-space: pre-wrap;
}
.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 {
@@ -84,24 +89,28 @@ img {
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
}
.spoiler--hidden .spoiler-image img {
.spoiler--hidden .spoiler-image * {
filter: blur(44px);
}
.spoiler--hidden .spoiler-image:after {
content: "SPOILER";
color: #dcddde;
background-color: rgba(0, 0, 0, 0.6);
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-weight: 600;
padding: 0.5em 0.7em;
border-radius: 20px;
letter-spacing: 0.05em;
font-size: 0.9em;
}
.spoiler--hidden .spoiler-image:after {
content: "SPOILER";
color: #dcddde;
background-color: rgba(0, 0, 0, 0.6);
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-weight: 600;
/* padding: 0.5em 0.7em; */
padding: 100%;
/* This ruins those beutifully rounded buttons, but it's needed to prevent a FireFox bug with video and audio elemnts. */
/* 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 {
color: #fff;
@@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{~ # Metadata ~}}
<title>{{ Context.Request.Guild.Name | html.escape }} - {{ Context.Request.Channel.Name | html.escape }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
{{~ # Styles ~}}
<style>
{{ CoreStyleSheet }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
});
</script>
{{~ # Local scripts ~}}
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
if (element) {
event.preventDefault();
element.classList.add('chatlog__message--highlighted');
window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
}
function showSpoiler(event, element) {
if (element && element.classList.contains('spoiler--hidden')) {
event.preventDefault();
element.classList.remove('spoiler--hidden');
}
}
</script>
</head>
<body>
{{~ # Preamble ~}}
<div class="preamble">
<div class="preamble__guild-icon-container">
<img class="preamble__guild-icon" src="{{ Context.Request.Guild.IconUrl | ResolveUrl }}" alt="Guild icon">
</div>
<div class="preamble__entries-container">
<div class="preamble__entry">{{ Context.Request.Guild.Name | html.escape }}</div>
<div class="preamble__entry">{{ Context.Request.Channel.Category | html.escape }} / {{ Context.Request.Channel.Name | html.escape }}</div>
{{~ if Context.Request.Channel.Topic ~}}
<div class="preamble__entry preamble__entry--small">{{ Context.Request.Channel.Topic | html.escape }}</div>
{{~ end ~}}
{{~ if Context.Request.After || Context.Request.Before ~}}
<div class="preamble__entry preamble__entry--small">
{{~ if Context.Request.After && Context.Request.Before ~}}
Between {{ Context.Request.After | FormatDate | html.escape }} and {{ Context.Request.Before | FormatDate | html.escape }}
{{~ else if Context.Request.After ~}}
After {{ Context.Request.After | FormatDate | html.escape }}
{{~ else if Context.Request.Before ~}}
Before {{ Context.Request.Before | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ # Log ~}}
<div class="chatlog">
{{~ %SPLIT% ~}}
</div>
{{~ # Postamble ~}}
<div class="postamble">
<div class="postamble__entry">Exported {{ MessageCount | object.format "N0" }} message(s)</div>
</div>
</body>
</html>
@@ -0,0 +1,18 @@
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
{
internal class LayoutTemplateContext
{
public ExportContext ExportContext { get; }
public string ThemeName { get; }
public long MessageCount { get; }
public LayoutTemplateContext(ExportContext exportContext, string themeName, long messageCount)
{
ExportContext = exportContext;
ThemeName = themeName;
MessageCount = messageCount;
}
}
}
@@ -0,0 +1,245 @@
@using System
@using System.Linq
@using System.Threading.Tasks
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.MessageGroupTemplateContext>
@{
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
var userMember = Model.ExportContext.TryGetMember(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 userColorStyle = userColor != null
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
: null;
}
<div class="chatlog__message-group">
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar">
</div>
<div class="chatlog__messages">
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
@if (Model.MessageGroup.Author.IsBot)
{
<span class="chatlog__bot-tag">BOT</span>
}
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
@foreach (var message in Model.MessageGroup.Messages)
{
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp != null)
{
<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)
{
<div class="chatlog__attachment">
<div class="@(attachment.IsSpoiler ? "spoiler spoiler--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
<div class="@(attachment.IsSpoiler ? "spoiler-image" : "")">
@if (attachment.IsImage)
{
<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
{
<a href="@await ResolveUrlAsync(attachment.Url)">
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
</a>
}
</div>
</div>
</div>
}
@foreach (var embed in message.Embeds)
{
<div class="chatlog__embed">
@if (embed.Color != null)
{
var embedColorStyle = $"background-color: rgba({embed.Color?.R},{embed.Color?.G},{embed.Color?.B},{embed.Color?.A})";
<div class="chatlog__embed-color-pill" style="@embedColorStyle"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
@if (embed.Author != null)
{
<div class="chatlog__embed-author">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconUrl)" alt="Author icon">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
<span class="chatlog__embed-author-name">
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
}
else
{
@embed.Author.Name
}
</span>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a>
}
else
{
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(embed.Description))
{
<div class="chatlog__embed-description">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
</div>
}
@if (embed.Fields.Any())
{
<div class="chatlog__embed-fields">
@foreach (var field in embed.Fields)
{
var isInlineStyle = field.IsInline ? "chatlog__embed-field--inline" : null;
<div class="chatlog__embed-field @isInlineStyle">
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
</div>
}
</div>
}
</div>
}
</div>
@if (embed.Thumbnail != null)
{
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.Url)" alt="Thumbnail">
</a>
</div>
}
</div>
@if (embed.Image != null)
{
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.Url)" alt="Image">
</a>
</div>
}
@if (embed.Footer != null || embed.Timestamp != null)
{
<div class="chatlog__embed-footer">
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
}
<span class="chatlog__embed-footer-text">
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
{
@embed.Footer.Text
}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
{
@(" • ")
}
@if (embed.Timestamp != null)
{
@FormatDate(embed.Timestamp.Value)
}
</span>
</div>
}
</div>
</div>
}
@if (message.Reactions.Any())
{
<div class="chatlog__reactions">
@foreach (var reaction in message.Reactions)
{
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" title="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
<span class="chatlog__reaction-count">@reaction.Count</span>
</div>
}
</div>
}
</div>
}
</div>
</div>
@@ -1,184 +0,0 @@
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ MessageGroup.Author.AvatarUrl | ResolveUrl }}" alt="Avatar">
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
{{~ userColor = TryGetUserColor MessageGroup.Author | FormatColorRgb ~}}
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if userColor }} style="color: {{ userColor }}" {{ end }}>{{ (TryGetUserNick MessageGroup.Author ?? MessageGroup.Author.Name) | html.escape }}</span>
{{~ # Bot tag ~}}
{{~ if MessageGroup.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span>
{{~ end ~}}
<span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in MessageGroup.Messages ~}}
<div class="chatlog__message {{ if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
<div class="markdown">
{{- message.Content | FormatMarkdown -}}
{{- # Edited timestamp -}}
{{- if message.EditedTimestamp -}}
{{-}}<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>{{-}}
{{- end -}}
</div>
</div>
{{~ end ~}}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
{{ # Spoiler image }}
{{~ if attachment.IsSpoiler ~}}
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
<div class="spoiler-image">
<a href="{{ attachment.Url | ResolveUrl }}">
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url | ResolveUrl }}" alt="Attachment">
</a>
</div>
</div>
{{~ else ~}}
<a href="{{ attachment.Url | ResolveUrl }}">
{{ # Non-spoiler image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url | ResolveUrl }}" alt="Attachment">
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
{{~ if embed.Color ~}}
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
{{~ else ~}}
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
{{~ end ~}}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl | ResolveUrl }}" alt="Author icon">
{{~ end ~}}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div></a>
{{~ else ~}}
<div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description"><div class="markdown">{{ embed.Description | FormatEmbedMarkdown }}</div></div>
{{~ end ~}}
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name"><div class="markdown">{{ field.Name | FormatEmbedMarkdown }}</div></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value"><div class="markdown">{{ field.Value | FormatEmbedMarkdown }}</div></div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url | ResolveUrl }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url | ResolveUrl }}" alt="Thumbnail">
</a>
</div>
{{~ end ~}}
</div>
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url | ResolveUrl }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url | ResolveUrl }}" alt="Image">
</a>
</div>
{{~ end ~}}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl | ResolveUrl }}" alt="Footer icon">
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{~ end ~}}
{{~ end ~}}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl | ResolveUrl }}">
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
@@ -0,0 +1,20 @@
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
{
internal class MessageGroupTemplateContext
{
public ExportContext ExportContext { get; }
public MessageGroup MessageGroup { get; }
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
{
ExportContext = exportContext;
MessageGroup = messageGroup;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
}
}
@@ -0,0 +1,10 @@
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
</div>
<div class="postamble">
<div class="postamble__entry">Exported @Model.MessageCount.ToString("N0") message(s)</div>
</div>
</body>
</html>
@@ -0,0 +1,100 @@
@using System
@using System.Threading.Tasks
@using Tyrrrz.Extensions
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
@{
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string GetStyleSheet(string name) => Model.GetType().Assembly.GetManifestResourceString($"DiscordChatExporter.Domain.Exporting.Writers.Html.{name}.css");
}
<!DOCTYPE html>
<html lang="en">
<head>
<title>@Model.ExportContext.Request.Guild.Name - @Model.ExportContext.Request.Channel.Name</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<style>
@Raw(GetStyleSheet("Core"))
</style>
<style>
@Raw(GetStyleSheet(Model.ThemeName))
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
});
</script>
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
if (element) {
event.preventDefault();
element.classList.add('chatlog__message--highlighted');
window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
}
function showSpoiler(event, element) {
if (element && element.classList.contains('spoiler--hidden')) {
event.preventDefault();
element.classList.remove('spoiler--hidden');
}
}
</script>
</head>
<body>
<div class="preamble">
<div class="preamble__guild-icon-container">
<img class="preamble__guild-icon" src="@await ResolveUrlAsync(Model.ExportContext.Request.Guild.IconUrl)" alt="Guild icon">
</div>
<div class="preamble__entries-container">
<div class="preamble__entry">@Model.ExportContext.Request.Guild.Name</div>
<div class="preamble__entry">@Model.ExportContext.Request.Channel.Category / @Model.ExportContext.Request.Channel.Name</div>
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic))
{
<div class="preamble__entry preamble__entry--small">@Model.ExportContext.Request.Channel.Topic</div>
}
@if (Model.ExportContext.Request.After != null || Model.ExportContext.Request.Before != null)
{
<div class="preamble__entry preamble__entry--small">
@if (Model.ExportContext.Request.After != null && Model.ExportContext.Request.Before != null)
{
@($"Between {FormatDate(Model.ExportContext.Request.After.Value)} and {FormatDate(Model.ExportContext.Request.Before.Value)}")
}
else if (Model.ExportContext.Request.After != null)
{
@($"After {FormatDate(Model.ExportContext.Request.After.Value)}")
}
else if (Model.ExportContext.Request.Before != null)
{
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value)}")
}
</div>
}
</div>
</div>
<div class="chatlog">
@@ -0,0 +1,65 @@
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using MiniRazor;
using Tyrrrz.Extensions;
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PreambleTemplateAssemblyName)]
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.MessageGroupTemplateAssemblyName)]
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PostambleTemplateAssemblyName)]
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
{
internal partial class TemplateBundle
{
public const string PreambleTemplateAssemblyName = "RazorAssembly_Preamble";
public const string MessageGroupTemplateAssemblyName = "RazorAssembly_MessageGroup";
public const string PostambleTemplateAssemblyName = "RazorAssembly_Postamble";
public MiniRazorTemplateDescriptor PreambleTemplate { get; }
public MiniRazorTemplateDescriptor MessageGroupTemplate { get; }
public MiniRazorTemplateDescriptor PostambleTemplate { get; }
public TemplateBundle(
MiniRazorTemplateDescriptor preambleTemplate,
MiniRazorTemplateDescriptor messageGroupTemplate,
MiniRazorTemplateDescriptor postambleTemplate)
{
PreambleTemplate = preambleTemplate;
MessageGroupTemplate = messageGroupTemplate;
PostambleTemplate = postambleTemplate;
}
}
internal partial class TemplateBundle
{
private static TemplateBundle? _lastBundle;
// This is very CPU-heavy
private static async ValueTask<TemplateBundle> CompileAsync() => await Task.Run(() =>
{
var ns = typeof(TemplateBundle).Namespace!;
var preambleTemplateSource = typeof(HtmlMessageWriter).Assembly
.GetManifestResourceString($"{ns}.PreambleTemplate.cshtml");
var messageGroupTemplateSource = typeof(HtmlMessageWriter).Assembly
.GetManifestResourceString($"{ns}.MessageGroupTemplate.cshtml");
var postambleTemplateSource = typeof(HtmlMessageWriter).Assembly
.GetManifestResourceString($"{ns}.PostambleTemplate.cshtml");
var engine = new MiniRazorTemplateEngine();
var preambleTemplate = engine.Compile(preambleTemplateSource, PreambleTemplateAssemblyName, ns);
var messageGroupTemplate = engine.Compile(messageGroupTemplateSource, MessageGroupTemplateAssemblyName, ns);
var postambleTemplate = engine.Compile(postambleTemplateSource, PostambleTemplateAssemblyName, ns);
return new TemplateBundle(preambleTemplate, messageGroupTemplate, postambleTemplate);
});
public static async ValueTask<TemplateBundle> ResolveAsync() =>
_lastBundle ??= await CompileAsync();
}
}
@@ -1,31 +1,19 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting.Writers.Html;
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Domain.Internal.Extensions;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Exporting.Writers
{
internal partial class HtmlMessageWriter : MessageWriter
internal class HtmlMessageWriter : MessageWriter
{
private readonly TextWriter _writer;
private readonly string _themeName;
private readonly List<Message> _messageGroupBuffer = new List<Message>();
private readonly Template _preambleTemplate;
private readonly Template _messageGroupTemplate;
private readonly Template _postambleTemplate;
private long _messageCount;
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
@@ -33,89 +21,26 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
{
_writer = new StreamWriter(stream);
_themeName = themeName;
_preambleTemplate = Template.Parse(GetPreambleTemplateCode());
_messageGroupTemplate = Template.Parse(GetMessageGroupTemplateCode());
_postambleTemplate = Template.Parse(GetPostambleTemplateCode());
}
private TemplateContext CreateTemplateContext(IReadOnlyDictionary<string, object>? constants = null)
{
// Template context
var templateContext = new TemplateContext
{
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
// Model
var scriptObject = new ScriptObject();
// Constants
scriptObject.SetValue("Context", Context, true);
scriptObject.SetValue("CoreStyleSheet", GetCoreStyleSheetCode(), true);
scriptObject.SetValue("ThemeStyleSheet", GetThemeStyleSheetCode(_themeName), true);
scriptObject.SetValue("HighlightJsStyleName", $"solarized-{_themeName.ToLowerInvariant()}", true);
// Additional constants
if (constants != null)
{
foreach (var (member, value) in constants)
scriptObject.SetValue(member, value, true);
}
// Functions
scriptObject.Import("FormatDate",
new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.Request.DateFormat)));
scriptObject.Import("FormatColorRgb",
new Func<Color?, string?>(c => c != null ? $"rgb({c?.R}, {c?.G}, {c?.B})" : null));
scriptObject.Import("TryGetUserColor",
new Func<User, Color?>(Context.TryGetUserColor));
scriptObject.Import("TryGetUserNick",
new Func<User, string?>(u => Context.TryGetMember(u.Id)?.Nick));
scriptObject.Import("FormatMarkdown",
new Func<string?, string>(m => FormatMarkdown(m)));
scriptObject.Import("FormatEmbedMarkdown",
new Func<string?, string>(m => FormatMarkdown(m, false)));
// HACK: Scriban doesn't support async, so we have to resort to this and be careful about deadlocks.
// TODO: move to Razor.
scriptObject.Import("ResolveUrl",
new Func<string, string>(u => Context.ResolveMediaUrlAsync(u).GetAwaiter().GetResult()));
// Push model
templateContext.PushGlobal(scriptObject);
// Push output
templateContext.PushOutput(new TextWriterOutput(_writer));
return templateContext;
}
private string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(Context, markdown ?? "", isJumboAllowed);
private async ValueTask WriteCurrentMessageGroupAsync()
{
var templateContext = CreateTemplateContext(new Dictionary<string, object>
{
["MessageGroup"] = MessageGroup.Join(_messageGroupBuffer)
});
await templateContext.EvaluateAsync(_messageGroupTemplate.Page);
}
public override async ValueTask WritePreambleAsync()
{
var templateContext = CreateTemplateContext();
await templateContext.EvaluateAsync(_preambleTemplate.Page);
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
var templateBundle = await TemplateBundle.ResolveAsync();
await _writer.WriteLineAsync(
await templateBundle.PreambleTemplate.RenderAsync(templateContext)
);
}
private async ValueTask WriteMessageGroupAsync(MessageGroup messageGroup)
{
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
var templateBundle = await TemplateBundle.ResolveAsync();
await _writer.WriteLineAsync(
await templateBundle.MessageGroupTemplate.RenderAsync(templateContext)
);
}
public override async ValueTask WriteMessageAsync(Message message)
@@ -128,7 +53,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
// Otherwise, flush the group and render messages
else
{
await WriteCurrentMessageGroupAsync();
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
_messageGroupBuffer.Clear();
_messageGroupBuffer.Add(message);
@@ -142,14 +67,14 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
{
// Flush current message group
if (_messageGroupBuffer.Any())
await WriteCurrentMessageGroupAsync();
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
var templateContext = CreateTemplateContext(new Dictionary<string, object>
{
["MessageCount"] = _messageCount
});
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
var templateBundle = await TemplateBundle.ResolveAsync();
await templateContext.EvaluateAsync(_postambleTemplate.Page);
await _writer.WriteLineAsync(
await templateBundle.PostambleTemplate.RenderAsync(templateContext)
);
}
public override async ValueTask DisposeAsync()
@@ -158,32 +83,4 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
await base.DisposeAsync();
}
}
internal partial class HtmlMessageWriter
{
private static readonly Assembly ResourcesAssembly = typeof(HtmlMessageWriter).Assembly;
private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Exporting.Writers.Html";
private static string GetCoreStyleSheetCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.Core.css");
private static string GetThemeStyleSheetCode(string themeName) =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.{themeName}.css");
private static string GetPreambleTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.LayoutTemplate.html")
.SubstringUntil("{{~ %SPLIT% ~}}");
private static string GetMessageGroupTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.MessageGroupTemplate.html");
private static string GetPostambleTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.LayoutTemplate.html")
.SubstringAfter("{{~ %SPLIT% ~}}");
}
}
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Domain.Internal.Extensions;
using DiscordChatExporter.Domain.Markdown.Ast;
namespace DiscordChatExporter.Domain.Exporting.Writers
{
@@ -114,6 +115,9 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteString("timestamp", embed.Timestamp);
_writer.WriteString("description", FormatMarkdown(embed.Description));
if (embed.Color != null)
_writer.WriteString("color", embed.Color.Value.ToHex());
if (embed.Author != null)
await WriteEmbedAuthorAsync(embed.Author);
@@ -156,6 +160,20 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
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()
{
// Root object (start)
@@ -197,6 +215,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteString("type", message.Type.ToString());
_writer.WriteString("timestamp", message.Timestamp);
_writer.WriteString("timestampEdited", message.EditedTimestamp);
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
_writer.WriteBoolean("isPinned", message.IsPinned);
// Content
@@ -235,6 +254,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteEndArray();
// Mentions
_writer.WriteStartArray("mentions");
foreach (var mention in message.MentionedUsers)
await WriteMentionAsync(mention);
_writer.WriteEndArray();
_writer.WriteEndObject();
await _writer.FlushAsync();
@@ -4,7 +4,6 @@ using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Domain.Discord.Models;
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Domain.Internal.Extensions;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Domain.Exporting.Writers
@@ -27,7 +26,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
private async ValueTask WriteMessageHeaderAsync(Message message)
{
// Timestamp & author
await _writer.WriteAsync($"[{message.Timestamp.ToLocalString(Context.Request.DateFormat)}]");
await _writer.WriteAsync($"[{Context.FormatDate(message.Timestamp)}]");
await _writer.WriteAsync($" {message.Author.FullName}");
// Whether the message is pinned
@@ -120,10 +119,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
if (Context.Request.After != null)
await _writer.WriteLineAsync($"After: {Context.Request.After.Value.ToLocalString(Context.Request.DateFormat)}");
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value)}");
if (Context.Request.Before != null)
await _writer.WriteLineAsync($"Before: {Context.Request.Before.Value.ToLocalString(Context.Request.DateFormat)}");
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value)}");
await _writer.WriteLineAsync('='.Repeat(62));
await _writer.WriteLineAsync();
@@ -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 int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
}
@@ -7,14 +7,13 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
{
internal static class HttpClientExtensions
{
// HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter
public static async ValueTask DownloadAsync(this HttpClient httpClient, string uri, string outputFilePath)
{
await using var input = await httpClient.GetStreamAsync(uri).ConfigureAwait(false);
await using var input = await httpClient.GetStreamAsync(uri);
var output = File.Create(outputFilePath);
await input.CopyToAsync(output).ConfigureAwait(false);
await output.DisposeAsync().ConfigureAwait(false);
await input.CopyToAsync(output);
await output.DisposeAsync();
}
public static async ValueTask<JsonElement> ReadAsJsonAsync(this HttpContent content)
@@ -4,6 +4,11 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
{
internal static class StringExtensions
{
public static string Truncate(this string str, int charCount) =>
str.Length > charCount
? str.Substring(0, charCount)
: str;
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0
? builder.Append(value)
@@ -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));
}
}
@@ -0,0 +1,18 @@
using System.IO;
using System.Text;
namespace DiscordChatExporter.Domain.Internal
{
internal static class PathEx
{
public static StringBuilder EscapePath(StringBuilder pathBuffer)
{
foreach (var invalidChar in Path.GetInvalidFileNameChars())
pathBuffer.Replace(invalidChar, '_');
return pathBuffer;
}
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
}
}
@@ -1,23 +0,0 @@
using System;
using System.Net;
using System.Net.Http;
namespace DiscordChatExporter.Domain.Internal
{
internal static class Singleton
{
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
{
var handler = new HttpClientHandler();
if (handler.SupportsAutomaticDecompression)
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
handler.UseCookies = false;
return new HttpClient(handler, true);
});
public static HttpClient HttpClient { get; } = LazyHttpClient.Value;
}
}
@@ -9,7 +9,8 @@ namespace DiscordChatExporter.Domain.Utilities
{
public static class AsyncExtensions
{
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable)
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(
this IAsyncEnumerable<T> asyncEnumerable)
{
var list = new List<T>();
@@ -19,10 +20,14 @@ namespace DiscordChatExporter.Domain.Utilities
return list;
}
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) =>
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
this IAsyncEnumerable<T> asyncEnumerable) =>
asyncEnumerable.AggregateAsync().GetAwaiter();
public static async ValueTask ParallelForEachAsync<T>(this IEnumerable<T> source, Func<T, Task> handleAsync, int degreeOfParallelism)
public static async ValueTask ParallelForEachAsync<T>(
this IEnumerable<T> source,
Func<T, ValueTask> handleAsync,
int degreeOfParallelism)
{
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
+97 -70
View File
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:s="https://github.com/canton7/Stylet">
<Application.Resources>
<s:ApplicationLoader>
@@ -13,69 +14,15 @@
<!-- Merged dictionaries -->
<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.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 -->
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
@@ -83,7 +30,7 @@
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="TextElement.FontSize" Value="13" />
<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.TextRenderingMode" Value="Auto" />
<Setter Property="UseLayoutRounding" Value="True" />
@@ -93,23 +40,20 @@
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
<Setter Property="Height" Value="2" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Minimum" Value="0" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
<Style
x:Key="MaterialDesignFlatDarkButton"
@@ -136,6 +80,89 @@
</Trigger>
</Style.Triggers>
</Style>
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
<DockPanel Background="{TemplateBinding Background}">
<ToggleButton
Name="HeaderSite"
BorderThickness="0"
Content="{TemplateBinding Header}"
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
Cursor="Hand"
DockPanel.Dock="Top"
Focusable="False"
Foreground="{TemplateBinding Foreground}"
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
IsTabStop="False"
Opacity=".87"
Style="{StaticResource MaterialDesignExpanderDownHeaderStyle}"
TextElement.FontSize="15" />
<Border
Name="ContentSite"
DockPanel.Dock="Bottom"
Visibility="Collapsed">
<StackPanel
Name="ContentPanel"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
<ContentPresenter
Name="PART_Content"
ContentStringFormat="{TemplateBinding ContentStringFormat}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
Focusable="False" />
</StackPanel>
</Border>
</DockPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="true">
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Right">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderRightHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Left">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderLeftHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Up">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderUpHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Down">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderDownHeaderStyle}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</s:ApplicationLoader>
</Application.Resources>
</Application>
+37 -4
View File
@@ -1,16 +1,49 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Internal;
using MaterialDesignThemes.Wpf;
namespace DiscordChatExporter.Gui
{
public partial class App
{
private static readonly Assembly Assembly = typeof(App).Assembly;
private static Assembly Assembly { get; } = typeof(App).Assembly;
public static string Name => Assembly.GetName().Name!;
public static string Name { get; } = Assembly.GetName().Name!;
public static Version Version => Assembly.GetName().Version!;
public static Version Version { get; } = Assembly.GetName().Version!;
public static string VersionString => Version.ToString(3);
public static string VersionString { get; } = Version.ToString(3);
public static string GitHubProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
public static string GitHubProjectWikiUrl { get; } = GitHubProjectUrl + "/wiki";
}
public partial class App
{
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 _modelHandled;
public IList SelectedItems
public IList? SelectedItems
{
get => (IList) GetValue(SelectedItemsProperty);
get => (IList?) GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value);
}
+9
View File
@@ -13,6 +13,15 @@ namespace DiscordChatExporter.Gui
{
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)
{
base.ConfigureIoC(builder);
@@ -18,9 +18,7 @@ namespace DiscordChatExporter.Gui.Converters
return default(string);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
}
@@ -0,0 +1,28 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DiscordChatExporter.Gui.Converters
{
[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))]
public class TimeSpanToDateTimeConverter : IValueConverter
{
public static TimeSpanToDateTimeConverter Instance { get; } = new TimeSpanToDateTimeConverter();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is TimeSpan timeSpanValue)
return DateTime.Today.Add(timeSpanValue);
return default(DateTime?);
}
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is DateTime dateTimeValue)
return dateTimeValue.TimeOfDay;
return default(TimeSpan?);
}
}
}
@@ -18,11 +18,11 @@
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
<PackageReference Include="Onova" Version="2.6.0" />
<PackageReference Include="Stylet" Version="1.3.3" />
<PackageReference Include="Onova" Version="2.6.2" />
<PackageReference Include="Stylet" Version="1.3.4" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
<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>
+10
View File
@@ -11,6 +11,16 @@
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
</xs:annotation>
</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: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>
@@ -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
};
using (Process.Start(startInfo))
{ }
using (Process.Start(startInfo)) {}
}
}
}
@@ -6,14 +6,18 @@ namespace DiscordChatExporter.Gui.Services
{
public class SettingsService : SettingsManager
{
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public bool IsAutoUpdateEnabled { get; set; } = true;
public bool IsDarkModeEnabled { get; set; }
public bool IsTokenPersisted { get; set; } = true;
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
public int ParallelLimit { get; set; } = 1;
public bool ShouldReuseMedia { get; set; }
public AuthToken? LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
@@ -10,7 +10,8 @@ namespace DiscordChatExporter.Gui.Services
{
private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor());
new ZipPackageExtractor()
);
private readonly SettingsService _settingsService;
@@ -26,9 +26,23 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
public ExportFormat SelectedFormat { get; set; }
public DateTimeOffset? After { get; set; }
// This date/time abomination is required because we use separate controls to set these
public DateTimeOffset? Before { get; set; }
public DateTimeOffset? AfterDate { get; set; }
public bool IsAfterDateSet => AfterDate != null;
public TimeSpan? AfterTime { get; set; }
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
public DateTimeOffset? BeforeDate { get; set; }
public bool IsBeforeDateSet => BeforeDate != null;
public TimeSpan? BeforeTime { get; set; }
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
public int? PartitionLimit { get; set; }
@@ -60,17 +74,17 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
_settingsService.LastPartitionLimit = PartitionLimit;
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
// Clamp 'after' and 'before' values
if (After > Before)
After = Before;
if (Before < After)
Before = After;
// If single channel - prompt file path
if (IsSingleChannel)
if (Channels != null && IsSingleChannel)
{
var channel = Channels.Single();
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before);
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
Guild!,
channel,
SelectedFormat,
After,
Before
);
// Filter
var ext = SelectedFormat.GetFileExtension();
@@ -84,11 +98,24 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
OutputPath = _dialogManager.PromptDirectoryPath();
}
// If canceled - return
if (string.IsNullOrWhiteSpace(OutputPath))
return;
Close(true);
}
}
public static class ExportSetupViewModelExtensions
{
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
Guild guild, IReadOnlyList<Channel> channels)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
}
}
@@ -8,29 +8,41 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{
private readonly SettingsService _settingsService;
public string DateFormat
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
}
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value;
}
public bool IsDarkModeEnabled
{
get => _settingsService.IsDarkModeEnabled;
set => _settingsService.IsDarkModeEnabled = value;
}
public bool IsTokenPersisted
{
get => _settingsService.IsTokenPersisted;
set => _settingsService.IsTokenPersisted = value;
}
public string DateFormat
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
}
public int ParallelLimit
{
get => _settingsService.ParallelLimit;
set => _settingsService.ParallelLimit = value.Clamp(1, 10);
}
public bool ShouldReuseMedia
{
get => _settingsService.ShouldReuseMedia;
set => _settingsService.ShouldReuseMedia = value;
}
public SettingsViewModel(SettingsService settingsService)
{
@@ -19,13 +19,10 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
{
// Get the view that renders this viewmodel
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
// Set up event routing that will close the view when called from viewmodel
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
{
// Delegate to close the dialog and unregister event handler
void OnScreenClosed(object? o, EventArgs closeArgs)
{
openArgs.Session.Close();
@@ -35,37 +32,31 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
dialogScreen.Closed += OnScreenClosed;
}
// Show view
await DialogHost.Show(view, OnDialogOpened);
// Return the result
return dialogScreen.DialogResult;
}
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
{
// Create dialog
var dialog = new SaveFileDialog
{
Filter = filter,
AddExtension = true,
FileName = defaultFilePath,
DefaultExt = Path.GetExtension(defaultFilePath) ?? ""
DefaultExt = Path.GetExtension(defaultFilePath)
};
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
public string? PromptDirectoryPath(string defaultDirPath = "")
{
// Create dialog
var dialog = new VistaFolderBrowserDialog
{
SelectedPath = defaultDirPath
};
// Show dialog and return result
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
}
}
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
public void Close(T dialogResult = default)
{
DialogResult = dialogResult;
DialogResult = dialogResult!;
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.Exporting;
using DiscordChatExporter.Domain.Utilities;
using DiscordChatExporter.Gui.Internal;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress;
using MaterialDesignThemes.Wpf;
@@ -63,14 +65,21 @@ namespace DiscordChatExporter.Gui.ViewModels
// Update busy state when progress manager changes
ProgressManager.Bind(o => o.IsActive,
(sender, args) => IsBusy = ProgressManager.IsActive);
(sender, args) => IsBusy = ProgressManager.IsActive
);
ProgressManager.Bind(o => o.IsActive,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
(sender, args) => IsProgressIndeterminate =
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
);
ProgressManager.Bind(o => o.Progress,
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
(sender, args) => IsProgressIndeterminate =
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
);
}
private async ValueTask HandleAutoUpdateAsync()
private async ValueTask CheckForUpdatesAsync()
{
try
{
@@ -108,7 +117,16 @@ namespace DiscordChatExporter.Gui.ViewModels
TokenValue = _settingsService.LastToken.Value;
}
await HandleAutoUpdateAsync();
if (_settingsService.IsDarkModeEnabled)
{
App.SetDarkTheme();
}
else
{
App.SetLightTheme();
}
await CheckForUpdatesAsync();
}
protected override void OnClose()
@@ -125,6 +143,8 @@ namespace DiscordChatExporter.Gui.ViewModels
await _dialogManager.ShowDialogAsync(dialog);
}
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
@@ -134,7 +154,7 @@ namespace DiscordChatExporter.Gui.ViewModels
try
{
var tokenValue = TokenValue?.Trim('"');
var tokenValue = TokenValue?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(tokenValue))
return;
@@ -150,11 +170,7 @@ namespace DiscordChatExporter.Gui.ViewModels
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync())
{
var channels = await discord.GetGuildChannelsAsync(guild.Id);
guildChannelMap[guild] = channels
.OrderBy(c => c.Category)
.ThenBy(c => c.Name)
.ToArray();
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
}
GuildChannelMap = guildChannelMap;
@@ -182,8 +198,8 @@ namespace DiscordChatExporter.Gui.ViewModels
var exporter = new ChannelExporter(token);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0;
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
{
var (channel, operation) = tuple;
@@ -199,6 +215,7 @@ namespace DiscordChatExporter.Gui.ViewModels
dialog.Before,
dialog.PartitionLimit,
dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia,
_settingsService.DateFormat
);
@@ -48,12 +48,13 @@
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
TextTrimming="CharacterEllipsis"
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="/" />
<Run
Foreground="{DynamicResource PrimaryTextBrush}"
FontWeight="SemiBold"
Text="{Binding Channels[0].Name, Mode=OneWay}"
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock>
@@ -82,36 +83,58 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8"
materialDesign:HintAssist.Hint="From (optional)"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="After (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent after this date will be exported" />
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="Only include messages sent after this date" />
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="16,8"
materialDesign:HintAssist.Hint="To (optional)"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="Before (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent before this date will be exported" />
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="Only include messages sent before this date" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="0"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="Only include messages sent after this time" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="1"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="Only include messages sent before this time" />
</Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Messages per partition (optional)"
materialDesign:HintAssist.Hint="Messages per partition"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimit, TargetNullValue=''}"
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
ToolTip="Split output into partitions limited to this number of messages" />
<!-- Download media -->
<Grid Margin="16,16" ToolTip="If this is set, the export will include additional files such as user avatars, attached files, embedded images, etc">
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -120,7 +143,7 @@
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="Download referenced media content" />
Text="Download media" />
<ToggleButton
Grid.Column="1"
HorizontalAlignment="Right"
@@ -141,18 +164,13 @@
<ToggleButton
x:Name="AdvancedSectionToggleButton"
Grid.Column="0"
Margin="8"
Width="24"
Height="24"
Margin="12"
Cursor="Hand"
Loaded="AdvancedSectionToggleButton_OnLoaded"
Style="{DynamicResource MaterialDesignFlatToggleButton}"
ToolTip="Show advanced options">
<ToggleButton.Content>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Menu" />
</ToggleButton.Content>
</ToggleButton>
IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
ToolTip="Toggle advanced options" />
<Button
Grid.Column="2"
@@ -1,7 +1,4 @@
using System.Windows;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.Views.Dialogs
namespace DiscordChatExporter.Gui.Views.Dialogs
{
public partial class ExportSetupView
{
@@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
{
InitializeComponent();
}
private void AdvancedSectionToggleButton_OnLoaded(object sender, RoutedEventArgs e)
{
if (DataContext is ExportSetupViewModel vm)
AdvancedSectionToggleButton.IsChecked = vm.IsAdvancedSectionDisplayedByDefault;
}
}
}
@@ -7,7 +7,7 @@
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
Width="300"
Width="310"
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
@@ -15,17 +15,8 @@
<TextBlock
Margin="16"
FontSize="17"
Foreground="{DynamicResource PrimaryTextBrush}"
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 -->
<DockPanel
Background="Transparent"
@@ -41,6 +32,24 @@
IsChecked="{Binding IsAutoUpdateEnabled}" />
</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 -->
<DockPanel
Background="Transparent"
@@ -56,14 +65,37 @@
IsChecked="{Binding IsTokenPersisted}" />
</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 -->
<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 Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding ParallelLimit, Mode=OneWay}" />
<Run Text="{Binding ParallelLimit, Mode=OneWay}" />
</TextBlock>
<Slider
Margin="16,8"
Margin="16,4,16,8"
IsSnapToTickEnabled="True"
Maximum="10"
Minimum="1"
@@ -1,4 +1,6 @@
namespace DiscordChatExporter.Gui.Views.Dialogs
using System.Windows;
namespace DiscordChatExporter.Gui.Views.Dialogs
{
public partial class SettingsView
{
@@ -6,5 +8,11 @@
{
InitializeComponent();
}
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
App.SetDarkTheme();
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
App.SetLightTheme();
}
}
+93 -33
View File
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
@@ -23,6 +24,18 @@
<Window.TaskbarItemInfo>
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
</Window.TaskbarItemInfo>
<Window.Resources>
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Category" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Ascending" PropertyName="Category" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
</Window.Resources>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<Grid>
<Grid.RowDefinitions>
@@ -33,10 +46,7 @@
</Grid.RowDefinitions>
<!-- Toolbar -->
<Grid
Grid.Row="0"
Background="{DynamicResource PrimaryHueMidBrush}"
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -111,8 +121,12 @@
Margin="6"
Padding="4"
Command="{s:Action ShowSettings}"
Style="{DynamicResource MaterialDesignFlatDarkButton}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Settings">
<Button.Resources>
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
</Button.Resources>
<materialDesign:PackIcon
Width="24"
Height="24"
@@ -123,12 +137,20 @@
<!-- Progress bar -->
<ProgressBar
Grid.Row="1"
Background="{DynamicResource PrimaryHueMidBrush}"
Background="{DynamicResource MaterialDesignDarkBackground}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
<!-- 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 -->
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<!-- For user token -->
@@ -138,25 +160,25 @@
<Run Text="1. Open Discord" />
<LineBreak />
<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" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
<Run FontWeight="SemiBold" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="4. Select" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
<Run FontWeight="SemiBold" Text="Local Storage" />
<Run Text="&gt;" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discord.com" />
<Run FontWeight="SemiBold" Text="https://discord.com" />
<Run Text="on the left" />
<LineBreak />
<Run Text="5. Press" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="6. Find" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="at the bottom and copy the value" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
@@ -164,41 +186,55 @@
<LineBreak />
<Run Text="To authorize using bot token instead, click" />
<InlineUIContainer>
<materialDesign:PackIcon Margin="1,0,0,-3" Kind="Account" />
<materialDesign:PackIcon
Margin="1,0,0,-3"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
<!-- For bot token -->
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock FontSize="18" Text="Please provide your bot token to authorize" />
<TextBlock Margin="8,8,0,0" FontSize="14">
<TextBlock
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" />
<LineBreak />
<Run Text="2. Open your application's settings" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
<Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="4. Under" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
<Run FontWeight="SemiBold" Text="Copy" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="To authorize using user token instead, click" />
<InlineUIContainer>
<materialDesign:PackIcon Margin="1,0,0,-1" Kind="Robot" />
<materialDesign:PackIcon
Margin="1,0,0,-1"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run Text="in the text box above." />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="For more information, check out the" />
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." />
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock>
</StackPanel>
</Grid>
@@ -213,7 +249,7 @@
<!-- Guilds -->
<Border
Grid.Column="0"
BorderBrush="{DynamicResource DividerBrush}"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,1,0">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
@@ -232,13 +268,15 @@
Width="48"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource DividerBrush}" />
Fill="{DynamicResource MaterialDesignDivider}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4">
Margin="12,4,12,4"
Stroke="{DynamicResource MaterialDesignDivider}"
StrokeThickness="1">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
@@ -253,12 +291,37 @@
<Border Grid.Column="1">
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding AvailableChannels}"
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
SelectionMode="Extended"
TextSearch.TextPath="Model.Name">
TextSearch.TextPath="Model.Name"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<i:Interaction.Behaviors>
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</i:Interaction.Behaviors>
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
<Expander
Margin="0"
Padding="0"
Background="Transparent"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,0,1"
Header="{Binding Name}"
IsExpanded="False">
<ItemsPresenter />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="-8" Background="Transparent">
@@ -278,16 +341,13 @@
VerticalAlignment="Center"
Kind="Pound" />
<!-- Channel category / name -->
<!-- Channel name -->
<TextBlock
Grid.Column="1"
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14">
<Run Foreground="{DynamicResource SecondaryTextBrush}" Text="{Binding Category, Mode=OneWay}" />
<Run Text="/" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding Name, Mode=OneWay}" />
</TextBlock>
FontSize="14"
Text="{Binding Name, Mode=OneWay}" />
<!-- Is selected checkmark -->
<materialDesign:PackIcon
+1 -10
View File
@@ -1,7 +1,4 @@
using System.Windows.Navigation;
using DiscordChatExporter.Gui.Internal;
namespace DiscordChatExporter.Gui.Views
namespace DiscordChatExporter.Gui.Views
{
public partial class RootView
{
@@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views
{
InitializeComponent();
}
private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e)
{
ProcessEx.StartShellExecute(e.Uri.AbsoluteUri);
e.Handled = true;
}
}
}
+2 -1
View File
@@ -2,11 +2,12 @@
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<Version>2.21.1</Version>
<Version>2.25</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
</Project>
+2 -2
View File
@@ -1,5 +1,5 @@
# Build
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
FROM mcr.microsoft.com/dotnet/sdk:3.1 AS build
WORKDIR /src
COPY favicon.ico ./
@@ -10,7 +10,7 @@ COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
RUN dotnet publish DiscordChatExporter.Cli -o DiscordChatExporter.Cli/publish -c Release
# Run
FROM mcr.microsoft.com/dotnet/core/runtime:3.1 AS run
FROM mcr.microsoft.com/dotnet/runtime:3.1 AS run
WORKDIR /app
COPY --from=build /src/DiscordChatExporter.Cli/publish ./
+10 -6
View File
@@ -4,22 +4,26 @@
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![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)
**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.
_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
- **[Latest release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**
- [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)
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 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 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 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 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 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
@@ -36,4 +40,4 @@ Note: This application requires .NET Core runtime in order to run:
## Screenshots
![channel list](.screenshots/list.png)
![rendered output](.screenshots/output.png)
![rendered output](.screenshots/output.png)