mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df98b0405 | |||
| 0d2b9e8bc0 | |||
| 53cebd82eb | |||
| 37fcee2f21 | |||
| 047dccef7f | |||
| cb47902d10 | |||
| dca8b8ceb2 | |||
| a8e7c85b99 | |||
| edd51e0838 | |||
| 5e04accdff | |||
| 547bf0a463 | |||
| 948fab7c86 | |||
| 5038b8541e | |||
| 1da80956dd | |||
| 0763a99765 | |||
| eda5b21684 | |||
| 2c7b054c24 | |||
| 520e023aff | |||
| 949c9d3f1e | |||
| fa825fce47 | |||
| 09acfcff59 | |||
| ddb4856516 | |||
| c4007d4a80 | |||
| 2a432c0ed2 | |||
| c5eeb5f464 | |||
| a6bf546e79 | |||
| 239c47c06e | |||
| 19f678ca01 | |||
| 08442f21b7 | |||
| 921f348769 | |||
| a25e809671 | |||
| fa80c82468 | |||
| 91f4f02a35 | |||
| 6d2880ce26 | |||
| 355b8cb8cf | |||
| 82945ac3cf | |||
| 5009b90a3e |
+47
-47
@@ -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
@@ -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 |
@@ -1,3 +1,26 @@
|
||||
### v2.25 (23-Nov-2020)
|
||||
|
||||
- [JSON] Added `mentions` property to the output. This contains users referenced inside the message text, but doesn't include mentions in embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- [HTML] Added better previews for attached video and audio. They are now rendered using a browser native player element. The actual file path can still be retrieved by right clicking and selecting "Copy file path". (Thanks [@sas41](https://github.com/sas41))
|
||||
- [CLI] Fixed an issue where using `--media` option without providing a rooted path via `--output` option triggered an error.
|
||||
- [CLI] Fixed an issue where using `--reuse-media` option without also providing `--media` option was accepted, but didn't actually do anything. Now this produces an error instead. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
|
||||
### v2.24 (24-Oct-2020)
|
||||
|
||||
- [CLI] Updated to [CliFx v1.5](https://github.com/Tyrrrz/CliFx), which brings some UX improvements.
|
||||
- [GUI] Added dark mode, which can be enabled in settings. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- [GUI] Improved the UX in a few minor places.
|
||||
- [JSON] Added `embed.color` property to the output. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Added additional date formats: `unix` to write dates as a unix timestamp and `unixms` for the milliseconds version. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Added retry policy for downloading media content. This is mostly a workaround for transient failures that sometimes may occur when downloading assets referenced from embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- Added an option to reuse already downloaded media to avoid redundant network requests. It can be enabled in settings in GUI, or by passing `--reuse-media` in CLI. To facilitate this feature, the file name strategy has been changed as well -- now instead of relying on the original file name and adding `(n)` to the end in case of duplicates, a URL hash is injected in every file name. This also means that when the "reuse media" option is disabled, existing files inside the media directory may be overwritten. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
|
||||
### v2.23 (14-Sep-2020)
|
||||
|
||||
- [CLI] Added a command to export all channels across all servers. Use `exportall` to do it. (Thanks [@wyattscarpenter](https://github.com/wyattscarpenter))
|
||||
- [HTML] Fixed an issue where Whitney fonts were not being loaded properly, causing the browser to fall back to Helvetica.
|
||||
- Fixed an issue where self-contained export crashed occasionally. This usually happened when the server hosting the file did not serve the stream properly. Such files are now ignored after the first failed attempt.
|
||||
|
||||
### v2.22 (12-Aug-2020)
|
||||
|
||||
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using 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.3.2" />
|
||||
<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,
|
||||
|
||||
@@ -34,10 +34,15 @@ 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) => date.ToLocalString(Request.DateFormat);
|
||||
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
||||
{
|
||||
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||
var dateFormat => date.ToLocalString(dateFormat)
|
||||
};
|
||||
|
||||
public Member? TryGetMember(string id) =>
|
||||
Members.FirstOrDefault(m => m.Id == id);
|
||||
@@ -69,10 +74,13 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
var filePath = await _mediaDownloader.DownloadAsync(url);
|
||||
|
||||
// We want relative path so that the output files can be copied around without breaking
|
||||
var relativeFilePath = 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;
|
||||
|
||||
// For HTML, we need to format the URL properly
|
||||
// HACK: for HTML, we need to format the URL properly
|
||||
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
|
||||
{
|
||||
// Need to escape each path segment while keeping the directory separators intact
|
||||
@@ -84,8 +92,12 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
public bool ShouldDownloadMedia { get; }
|
||||
|
||||
public bool ShouldReuseMedia { get; }
|
||||
|
||||
public string DateFormat { get; }
|
||||
|
||||
public ExportRequest(
|
||||
@@ -41,6 +43,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
DateTimeOffset? before,
|
||||
int? partitionLimit,
|
||||
bool shouldDownloadMedia,
|
||||
bool shouldReuseMedia,
|
||||
string dateFormat)
|
||||
{
|
||||
Guild = guild;
|
||||
@@ -51,6 +54,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
Before = before;
|
||||
PartitionLimit = partitionLimit;
|
||||
ShouldDownloadMedia = shouldDownloadMedia;
|
||||
ShouldReuseMedia = shouldReuseMedia;
|
||||
DateFormat = dateFormat;
|
||||
|
||||
OutputBaseFilePath = GetOutputBaseFilePath(
|
||||
|
||||
@@ -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;
|
||||
@@ -11,45 +13,74 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _workingDirPath;
|
||||
private readonly bool _reuseMedia;
|
||||
|
||||
private readonly 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;
|
||||
}
|
||||
|
||||
public MediaDownloader(string workingDirPath, bool reuseMedia)
|
||||
: this(Http.Client, workingDirPath, reuseMedia) {}
|
||||
|
||||
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 = PathEx.MakeUniqueFilePath(Path.Combine(_workingDirPath, fileName));
|
||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||
|
||||
// Reuse existing files if we're allowed to
|
||||
if (_reuseMedia && File.Exists(filePath))
|
||||
return _pathCache[url] = filePath;
|
||||
|
||||
// Download it
|
||||
Directory.CreateDirectory(_workingDirPath);
|
||||
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
// This catches IOExceptions which is dangerous as we're working also with files
|
||||
await _httpClient.DownloadAsync(url, filePath);
|
||||
});
|
||||
|
||||
await _httpClient.DownloadAsync(url, filePath);
|
||||
|
||||
return _pathMap[url] = filePath;
|
||||
return _pathCache[url] = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
|
||||
private static string GetUrlHash(string url)
|
||||
{
|
||||
using var hash = SHA256.Create();
|
||||
|
||||
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
||||
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
{
|
||||
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
var urlHash = GetUrlHash(url);
|
||||
|
||||
var fileName = !string.IsNullOrWhiteSpace(originalFileName)
|
||||
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(50)}{Path.GetExtension(originalFileName)}"
|
||||
: GetRandomFileName();
|
||||
// Try to extract file name from URL
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
return PathEx.EscapePath(fileName);
|
||||
// If it's not there, just use the URL hash as the file name
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
return urlHash;
|
||||
|
||||
// Otherwise, use the original file name but inject the hash in the middle
|
||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||
var fileExtension = Path.GetExtension(fileName);
|
||||
|
||||
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,9 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
internal partial class MessageExporter
|
||||
{
|
||||
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
|
||||
private static string GetPartitionFilePath(
|
||||
string baseFilePath,
|
||||
int partitionIndex)
|
||||
{
|
||||
// First partition - don't change file name
|
||||
if (partitionIndex <= 0)
|
||||
@@ -82,16 +84,17 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||
var fileExt = Path.GetExtension(baseFilePath);
|
||||
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
||||
|
||||
// Generate new path
|
||||
var dirPath = Path.GetDirectoryName(baseFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(dirPath))
|
||||
return Path.Combine(dirPath, fileName);
|
||||
|
||||
return fileName;
|
||||
return !string.IsNullOrWhiteSpace(dirPath)
|
||||
? Path.Combine(dirPath, fileName)
|
||||
: fileName;
|
||||
}
|
||||
|
||||
private static MessageWriter CreateMessageWriter(string filePath, ExportFormat format, ExportContext context)
|
||||
private static MessageWriter CreateMessageWriter(
|
||||
string filePath,
|
||||
ExportFormat format,
|
||||
ExportContext context)
|
||||
{
|
||||
// Stream will be disposed by the underlying writer
|
||||
var stream = File.Create(filePath);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
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 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})"
|
||||
@@ -40,36 +40,51 @@
|
||||
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
|
||||
|
||||
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
|
||||
<div class="chatlog__content">
|
||||
<div class="markdown">@Raw(FormatMarkdown(message.Content)) @if (message.EditedTimestamp != null) {<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>}</div>
|
||||
</div>
|
||||
@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">
|
||||
@if (attachment.IsSpoiler)
|
||||
{
|
||||
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
|
||||
<div class="spoiler-image">
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<div class="@(attachment.IsSpoiler ? "spoiler spoiler--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
|
||||
<div class="@(attachment.IsSpoiler ? "spoiler-image" : "")">
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</a>
|
||||
}
|
||||
else if (attachment.IsVideo)
|
||||
{
|
||||
<video controls class="chatlog__attachment-thumbnail">
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</video>
|
||||
}
|
||||
else if (attachment.IsAudio)
|
||||
{
|
||||
<audio controls class="chatlog__attachment-thumbnail">
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
|
||||
</a>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -119,12 +134,12 @@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -132,7 +147,7 @@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -147,14 +162,14 @@
|
||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
{
|
||||
<div class="chatlog__embed-field-name">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
<div class="chatlog__embed-field-value">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -190,22 +205,22 @@
|
||||
<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
|
||||
}
|
||||
<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 (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@if (embed.Timestamp != null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
@if (embed.Timestamp != null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Polly;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal
|
||||
{
|
||||
internal static class Http
|
||||
{
|
||||
public static HttpClient Client { get; } = new HttpClient();
|
||||
|
||||
public static IAsyncPolicy<HttpResponseMessage> ResponsePolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>()
|
||||
.Or<HttpRequestException>()
|
||||
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
|
||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(8,
|
||||
(i, result, ctx) =>
|
||||
{
|
||||
// If rate-limited, use retry-after as a guide
|
||||
if (result.Result.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
// Only start respecting retry-after after a few attempts.
|
||||
// The reason is that Discord often sends unreasonable (20+ minutes) retry-after
|
||||
// on the very first request.
|
||||
if (i > 3)
|
||||
{
|
||||
var retryAfterDelay = result.Result.Headers.RetryAfter.Delta;
|
||||
if (retryAfterDelay != null)
|
||||
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
|
||||
}
|
||||
}
|
||||
|
||||
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
|
||||
},
|
||||
(response, timespan, retryCount, context) => Task.CompletedTask);
|
||||
|
||||
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex)
|
||||
{
|
||||
// This is extremely frail, but there's no other way
|
||||
var statusCodeRaw = Regex.Match(ex.Message, @": (\d+) \(").Groups[1].Value;
|
||||
return !string.IsNullOrWhiteSpace(statusCodeRaw)
|
||||
? (HttpStatusCode) int.Parse(statusCodeRaw, CultureInfo.InvariantCulture)
|
||||
: (HttpStatusCode?) null;
|
||||
}
|
||||
|
||||
public static IAsyncPolicy ExceptionPolicy { get; } =
|
||||
Policy
|
||||
.Handle<IOException>() // dangerous
|
||||
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.TooManyRequests)
|
||||
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.RequestTimeout)
|
||||
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) >= HttpStatusCode.InternalServerError)
|
||||
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
|
||||
}
|
||||
}
|
||||
@@ -14,27 +14,5 @@ namespace DiscordChatExporter.Domain.Internal
|
||||
}
|
||||
|
||||
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
||||
|
||||
public static string MakeUniqueFilePath(string baseFilePath, int maxAttempts = int.MaxValue)
|
||||
{
|
||||
if (!File.Exists(baseFilePath))
|
||||
return baseFilePath;
|
||||
|
||||
var baseDirPath = Path.GetDirectoryName(baseFilePath);
|
||||
var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||
var baseFileExtension = Path.GetExtension(baseFilePath);
|
||||
|
||||
for (var i = 1; i <= maxAttempts; i++)
|
||||
{
|
||||
var filePath = $"{baseFileNameWithoutExtension} ({i}){baseFileExtension}";
|
||||
if (!string.IsNullOrWhiteSpace(baseDirPath))
|
||||
filePath = Path.Combine(baseDirPath, filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return baseFilePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal
|
||||
{
|
||||
internal static class Singleton
|
||||
{
|
||||
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
|
||||
{
|
||||
var handler = new HttpClientHandler();
|
||||
|
||||
if (handler.SupportsAutomaticDecompression)
|
||||
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
||||
|
||||
handler.UseCookies = false;
|
||||
|
||||
return new HttpClient(handler, true);
|
||||
});
|
||||
|
||||
public static HttpClient HttpClient { get; } = LazyHttpClient.Value;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,8 @@ namespace DiscordChatExporter.Domain.Utilities
|
||||
{
|
||||
public static class AsyncExtensions
|
||||
{
|
||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable)
|
||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable)
|
||||
{
|
||||
var list = new List<T>();
|
||||
|
||||
@@ -19,10 +20,14 @@ namespace DiscordChatExporter.Domain.Utilities
|
||||
return list;
|
||||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||
asyncEnumerable.AggregateAsync().GetAwaiter();
|
||||
|
||||
public static async ValueTask ParallelForEachAsync<T>(this IEnumerable<T> source, Func<T, Task> handleAsync, int degreeOfParallelism)
|
||||
public static async ValueTask ParallelForEachAsync<T>(
|
||||
this IEnumerable<T> source,
|
||||
Func<T, ValueTask> handleAsync,
|
||||
int degreeOfParallelism)
|
||||
{
|
||||
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
||||
|
||||
|
||||
@@ -14,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}" />
|
||||
@@ -84,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" />
|
||||
@@ -94,27 +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}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
||||
</Style>
|
||||
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
|
||||
|
||||
<Style
|
||||
x:Key="MaterialDesignFlatDarkButton"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.1" />
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -75,10 +75,16 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||
|
||||
// If single channel - prompt file path
|
||||
if (IsSingleChannel)
|
||||
if (Channels != null && IsSingleChannel)
|
||||
{
|
||||
var channel = Channels.Single();
|
||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before);
|
||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
||||
Guild!,
|
||||
channel,
|
||||
SelectedFormat,
|
||||
After,
|
||||
Before
|
||||
);
|
||||
|
||||
// Filter
|
||||
var ext = SelectedFormat.GetFileExtension();
|
||||
@@ -92,11 +98,24 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||
OutputPath = _dialogManager.PromptDirectoryPath();
|
||||
}
|
||||
|
||||
// If canceled - return
|
||||
if (string.IsNullOrWhiteSpace(OutputPath))
|
||||
return;
|
||||
|
||||
Close(true);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ExportSetupViewModelExtensions
|
||||
{
|
||||
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
|
||||
Guild guild, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
var viewModel = factory.CreateExportSetupViewModel();
|
||||
|
||||
viewModel.Guild = guild;
|
||||
viewModel.Channels = channels;
|
||||
|
||||
return viewModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -178,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;
|
||||
@@ -195,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>
|
||||
@@ -94,7 +95,7 @@
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
ToolTip="If this is set, only messages sent after this date will be exported" />
|
||||
ToolTip="Only include messages sent after this date" />
|
||||
<DatePicker
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
@@ -103,7 +104,7 @@
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||
ToolTip="If this is set, only messages sent before this date will be exported" />
|
||||
ToolTip="Only include messages sent before this date" />
|
||||
<materialDesign:TimePicker
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
@@ -112,7 +113,7 @@
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsEnabled="{Binding IsAfterDateSet}"
|
||||
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||
ToolTip="If this is set, only messages sent after this time will be exported" />
|
||||
ToolTip="Only include messages sent after this time" />
|
||||
<materialDesign:TimePicker
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
@@ -121,7 +122,7 @@
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
IsEnabled="{Binding IsBeforeDateSet}"
|
||||
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||
ToolTip="If this is set, only messages sent before this time will be exported" />
|
||||
ToolTip="Only include messages sent before this time" />
|
||||
</Grid>
|
||||
|
||||
<!-- Partitioning -->
|
||||
@@ -130,10 +131,10 @@
|
||||
materialDesign:HintAssist.Hint="Messages per partition"
|
||||
materialDesign:HintAssist.IsFloating="True"
|
||||
Text="{Binding PartitionLimit, TargetNullValue=''}"
|
||||
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
|
||||
ToolTip="Split output into partitions limited to this number of messages" />
|
||||
|
||||
<!-- Download media -->
|
||||
<Grid Margin="16,16" ToolTip="If this is set, the export will include additional files such as user avatars, attached files, embedded images, etc">
|
||||
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -142,7 +143,7 @@
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="Download referenced media content" />
|
||||
Text="Download media" />
|
||||
<ToggleButton
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
@@ -163,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();
|
||||
}
|
||||
}
|
||||
@@ -46,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" />
|
||||
@@ -124,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"
|
||||
@@ -136,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 -->
|
||||
@@ -151,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=">" />
|
||||
<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">
|
||||
@@ -177,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>
|
||||
@@ -226,7 +249,7 @@
|
||||
<!-- Guilds -->
|
||||
<Border
|
||||
Grid.Column="0"
|
||||
BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||
BorderThickness="0,0,1,0">
|
||||
<ListBox
|
||||
ItemsSource="{Binding AvailableGuilds}"
|
||||
@@ -245,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>
|
||||
@@ -284,8 +309,8 @@
|
||||
Margin="0"
|
||||
Padding="0"
|
||||
Background="Transparent"
|
||||
BorderBrush="{DynamicResource DividerBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Header="{Binding Name}"
|
||||
IsExpanded="False">
|
||||
<ItemsPresenter />
|
||||
@@ -322,7 +347,6 @@
|
||||
Margin="3,8,8,8"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
||||
Text="{Binding Name, Mode=OneWay}" />
|
||||
|
||||
<!-- Is selected checkmark -->
|
||||
|
||||
@@ -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,11 +2,12 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<Version>2.22</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
@@ -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 ./
|
||||
|
||||
@@ -6,21 +6,24 @@
|
||||
[](https://tyrrrz.me/donate)
|
||||
[](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
|
||||
|
||||
@@ -37,4 +40,4 @@ Note: This application requires .NET Core runtime in order to run:
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

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