mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-10 07:59:35 +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:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- "*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2.3.3
|
||||||
|
|
||||||
- name: Install .NET Core
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v1.4.0
|
uses: actions/setup-dotnet@v1.7.2
|
||||||
with:
|
with:
|
||||||
dotnet-version: 3.1.100
|
dotnet-version: 3.1.100
|
||||||
|
|
||||||
- name: Build & publish (CLI)
|
- name: Build & publish (CLI)
|
||||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
- name: Build & publish (GUI)
|
- name: Build & publish (GUI)
|
||||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
- name: Pack (CLI)
|
- name: Pack (CLI)
|
||||||
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
|
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Pack (GUI)
|
- name: Pack (GUI)
|
||||||
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
|
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Create release
|
- name: Create release
|
||||||
id: create_release
|
id: create_release
|
||||||
uses: actions/create-release@v1.0.1
|
uses: actions/create-release@v1.0.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref }}
|
tag_name: ${{ github.ref }}
|
||||||
release_name: ${{ github.ref }}
|
release_name: ${{ github.ref }}
|
||||||
body: |
|
body: |
|
||||||
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
- name: Upload release asset (CLI)
|
- name: Upload release asset (CLI)
|
||||||
uses: actions/upload-release-asset@v1.0.2
|
uses: actions/upload-release-asset@v1.0.2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
|
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
|
||||||
asset_name: DiscordChatExporter.CLI.zip
|
asset_name: DiscordChatExporter.CLI.zip
|
||||||
asset_content_type: application/zip
|
asset_content_type: application/zip
|
||||||
|
|
||||||
- name: Upload release asset (GUI)
|
- name: Upload release asset (GUI)
|
||||||
uses: actions/upload-release-asset@v1.0.2
|
uses: actions/upload-release-asset@v1.0.2
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
|
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
|
||||||
asset_name: DiscordChatExporter.zip
|
asset_name: DiscordChatExporter.zip
|
||||||
asset_content_type: application/zip
|
asset_content_type: application/zip
|
||||||
|
|||||||
+20
-20
@@ -7,28 +7,28 @@ jobs:
|
|||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2.3.3
|
||||||
|
|
||||||
- name: Install .NET Core
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v1.4.0
|
uses: actions/setup-dotnet@v1.7.2
|
||||||
with:
|
with:
|
||||||
dotnet-version: 3.1.100
|
dotnet-version: 3.1.100
|
||||||
|
|
||||||
- name: Build & publish (CLI)
|
- name: Build & publish (CLI)
|
||||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
- name: Build & publish (GUI)
|
- name: Build & publish (GUI)
|
||||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
- name: Upload build artifacts (CLI)
|
- name: Upload build artifacts (CLI)
|
||||||
uses: actions/upload-artifact@v1
|
uses: actions/upload-artifact@v1
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter.CLI
|
name: DiscordChatExporter.CLI
|
||||||
path: DiscordChatExporter.Cli/bin/Publish/
|
path: DiscordChatExporter.Cli/bin/Publish/
|
||||||
|
|
||||||
- name: Upload build artifact (GUI)
|
- name: Upload build artifact (GUI)
|
||||||
uses: actions/upload-artifact@v1
|
uses: actions/upload-artifact@v1
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter
|
name: DiscordChatExporter
|
||||||
path: DiscordChatExporter.Gui/bin/Publish/
|
path: DiscordChatExporter.Gui/bin/Publish/
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,3 +1,26 @@
|
|||||||
|
### v2.25 (23-Nov-2020)
|
||||||
|
|
||||||
|
- [JSON] Added `mentions` property to the output. This contains users referenced inside the message text, but doesn't include mentions in embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- [HTML] Added better previews for attached video and audio. They are now rendered using a browser native player element. The actual file path can still be retrieved by right clicking and selecting "Copy file path". (Thanks [@sas41](https://github.com/sas41))
|
||||||
|
- [CLI] Fixed an issue where using `--media` option without providing a rooted path via `--output` option triggered an error.
|
||||||
|
- [CLI] Fixed an issue where using `--reuse-media` option without also providing `--media` option was accepted, but didn't actually do anything. Now this produces an error instead. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
|
||||||
|
### v2.24 (24-Oct-2020)
|
||||||
|
|
||||||
|
- [CLI] Updated to [CliFx v1.5](https://github.com/Tyrrrz/CliFx), which brings some UX improvements.
|
||||||
|
- [GUI] Added dark mode, which can be enabled in settings. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- [GUI] Improved the UX in a few minor places.
|
||||||
|
- [JSON] Added `embed.color` property to the output. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Added additional date formats: `unix` to write dates as a unix timestamp and `unixms` for the milliseconds version. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Added retry policy for downloading media content. This is mostly a workaround for transient failures that sometimes may occur when downloading assets referenced from embeds. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Added an option to reuse already downloaded media to avoid redundant network requests. It can be enabled in settings in GUI, or by passing `--reuse-media` in CLI. To facilitate this feature, the file name strategy has been changed as well -- now instead of relying on the original file name and adding `(n)` to the end in case of duplicates, a URL hash is injected in every file name. This also means that when the "reuse media" option is disabled, existing files inside the media directory may be overwritten. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
|
||||||
|
### v2.23 (14-Sep-2020)
|
||||||
|
|
||||||
|
- [CLI] Added a command to export all channels across all servers. Use `exportall` to do it. (Thanks [@wyattscarpenter](https://github.com/wyattscarpenter))
|
||||||
|
- [HTML] Fixed an issue where Whitney fonts were not being loaded properly, causing the browser to fall back to Helvetica.
|
||||||
|
- Fixed an issue where self-contained export crashed occasionally. This usually happened when the server hosting the file did not serve the stream properly. Such files are now ignored after the first failed attempt.
|
||||||
|
|
||||||
### v2.22 (12-Aug-2020)
|
### v2.22 (12-Aug-2020)
|
||||||
|
|
||||||
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.IO;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
|
using CliFx.Exceptions;
|
||||||
using CliFx.Utilities;
|
using CliFx.Utilities;
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exporting;
|
using DiscordChatExporter.Domain.Exporting;
|
||||||
@@ -11,25 +12,36 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
{
|
{
|
||||||
public abstract class ExportCommandBase : TokenCommandBase
|
public abstract class ExportCommandBase : TokenCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
[CommandOption("output", 'o',
|
||||||
|
Description = "Output file or directory path.")]
|
||||||
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
|
public string OutputPath { get; set; } = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Output file format.")]
|
[CommandOption("format", 'f',
|
||||||
|
Description = "Export format.")]
|
||||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
[CommandOption("after", Description = "Limit to messages sent after this date.")]
|
[CommandOption("after",
|
||||||
|
Description = "Only include messages sent after this date.")]
|
||||||
public DateTimeOffset? After { get; set; }
|
public DateTimeOffset? After { get; set; }
|
||||||
|
|
||||||
[CommandOption("before", Description = "Limit to messages sent before this date.")]
|
[CommandOption("before",
|
||||||
|
Description = "Only include messages sent before this date.")]
|
||||||
public DateTimeOffset? Before { get; set; }
|
public DateTimeOffset? Before { get; set; }
|
||||||
|
|
||||||
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
|
[CommandOption("partition", 'p',
|
||||||
|
Description = "Split output into partitions limited to this number of messages.")]
|
||||||
public int? PartitionLimit { get; set; }
|
public int? PartitionLimit { get; set; }
|
||||||
|
|
||||||
[CommandOption("media", Description = "Download referenced media content.")]
|
[CommandOption("media",
|
||||||
|
Description = "Download referenced media content.")]
|
||||||
public bool ShouldDownloadMedia { get; set; }
|
public bool ShouldDownloadMedia { get; set; }
|
||||||
|
|
||||||
[CommandOption("dateformat", Description = "Date format used in output.")]
|
[CommandOption("reuse-media",
|
||||||
|
Description = "Reuse already existing media content to skip redundant downloads.")]
|
||||||
|
public bool ShouldReuseMedia { get; set; }
|
||||||
|
|
||||||
|
[CommandOption("dateformat",
|
||||||
|
Description = "Format used when writing dates.")]
|
||||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
|
||||||
protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient());
|
protected ChannelExporter GetChannelExporter() => new ChannelExporter(GetDiscordClient());
|
||||||
@@ -48,6 +60,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
ShouldDownloadMedia,
|
ShouldDownloadMedia,
|
||||||
|
ShouldReuseMedia,
|
||||||
DateFormat
|
DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,5 +81,17 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
var channel = await GetDiscordClient().GetChannelAsync(channelId);
|
var channel = await GetDiscordClient().GetChannelAsync(channelId);
|
||||||
await ExportAsync(console, channel);
|
await ExportAsync(console, channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
if (ShouldReuseMedia && !ShouldDownloadMedia)
|
||||||
|
{
|
||||||
|
throw new CommandException(
|
||||||
|
"The --reuse-media option cannot be used without the --media option."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -16,21 +17,23 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
{
|
{
|
||||||
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("parallel", Description = "Export this number of channels in parallel.")]
|
[CommandOption("parallel",
|
||||||
|
Description = "Limits how many channels can be exported in parallel.")]
|
||||||
public int ParallelLimit { get; set; } = 1;
|
public int ParallelLimit { get; set; } = 1;
|
||||||
|
|
||||||
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
// HACK: this uses a separate route from ExportCommandBase because the progress ticker is not thread-safe
|
// This uses a different route from ExportCommandBase.ExportAsync() because it runs
|
||||||
|
// in parallel and needs another way to report progress to console.
|
||||||
|
|
||||||
console.Output.Write($"Exporting {channels.Count} channels... ");
|
console.Output.Write($"Exporting {channels.Count} channels... ");
|
||||||
var progress = console.CreateProgressTicker();
|
var progress = console.CreateProgressTicker();
|
||||||
|
|
||||||
var operations = progress.Wrap().CreateOperations(channels.Count);
|
var operations = progress.Wrap().CreateOperations(channels.Count);
|
||||||
|
|
||||||
var errors = new List<string>();
|
|
||||||
|
|
||||||
var successfulExportCount = 0;
|
var successfulExportCount = 0;
|
||||||
|
var errors = new ConcurrentBag<string>();
|
||||||
|
|
||||||
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||||
{
|
{
|
||||||
var (channel, operation) = tuple;
|
var (channel, operation) = tuple;
|
||||||
@@ -48,6 +51,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
ShouldDownloadMedia,
|
ShouldDownloadMedia,
|
||||||
|
ShouldReuseMedia,
|
||||||
DateFormat
|
DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,15 +7,22 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
|||||||
{
|
{
|
||||||
public abstract class TokenCommandBase : ICommand
|
public abstract class TokenCommandBase : ICommand
|
||||||
{
|
{
|
||||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN",
|
[CommandOption("token", 't', IsRequired = true,
|
||||||
|
EnvironmentVariableName = "DISCORD_TOKEN",
|
||||||
Description = "Authorization token.")]
|
Description = "Authorization token.")]
|
||||||
public string TokenValue { get; set; } = "";
|
public string TokenValue { get; set; } = "";
|
||||||
|
|
||||||
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT",
|
[CommandOption("bot", 'b',
|
||||||
Description = "Whether this authorization token belongs to a bot.")]
|
EnvironmentVariableName = "DISCORD_TOKEN_BOT",
|
||||||
|
Description = "Authorize as a bot.")]
|
||||||
public bool IsBotToken { get; set; }
|
public bool IsBotToken { get; set; }
|
||||||
|
|
||||||
protected AuthToken GetAuthToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
protected AuthToken GetAuthToken() => new AuthToken(
|
||||||
|
IsBotToken
|
||||||
|
? AuthTokenType.Bot
|
||||||
|
: AuthTokenType.User,
|
||||||
|
TokenValue
|
||||||
|
);
|
||||||
|
|
||||||
protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken());
|
protected DiscordClient GetDiscordClient() => new DiscordClient(GetAuthToken());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CliFx;
|
||||||
|
using CliFx.Attributes;
|
||||||
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
|
{
|
||||||
|
[Command("exportall", Description = "Export all accessible channels.")]
|
||||||
|
public class ExportAllCommand : ExportMultipleCommandBase
|
||||||
|
{
|
||||||
|
[CommandOption("include-dm",
|
||||||
|
Description = "Include direct message channels.")]
|
||||||
|
public bool IncludeDirectMessages { get; set; } = true;
|
||||||
|
|
||||||
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
|
// Aggregate channels from all guilds
|
||||||
|
await foreach (var guild in GetDiscordClient().GetUserGuildsAsync())
|
||||||
|
{
|
||||||
|
// Skip DMs if instructed to
|
||||||
|
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await foreach (var channel in GetDiscordClient().GetGuildChannelsAsync(guild.Id))
|
||||||
|
{
|
||||||
|
channels.Add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExportMultipleAsync(console, channels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,14 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
[Command("export", Description = "Export a channel.")]
|
[Command("export", Description = "Export a channel.")]
|
||||||
public class ExportChannelCommand : ExportCommandBase
|
public class ExportChannelCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
|
[CommandOption("channel", 'c', IsRequired = true,
|
||||||
|
Description = "Channel ID.")]
|
||||||
public string ChannelId { get; set; } = "";
|
public string ChannelId { get; set; } = "";
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console) => await ExportAsync(console, ChannelId);
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
await ExportAsync(console, ChannelId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,8 +12,8 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||||
await ExportMultipleAsync(console, dmChannels);
|
await ExportMultipleAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,13 +9,14 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
||||||
public class ExportGuildCommand : ExportMultipleCommandBase
|
public class ExportGuildCommand : ExportMultipleCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption("guild", 'g', IsRequired = true,
|
||||||
|
Description = "Guild ID.")]
|
||||||
public string GuildId { get; set; } = "";
|
public string GuildId { get; set; } = "";
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||||
await ExportMultipleAsync(console, guildChannels);
|
await ExportMultipleAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,17 +7,18 @@ using DiscordChatExporter.Domain.Utilities;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands
|
namespace DiscordChatExporter.Cli.Commands
|
||||||
{
|
{
|
||||||
[Command("channels", Description = "Get the list of channels in specified guild.")]
|
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||||
public class GetChannelsCommand : TokenCommandBase
|
public class GetChannelsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption("guild", 'g', IsRequired = true,
|
||||||
|
Description = "Guild ID.")]
|
||||||
public string GuildId { get; set; } = "";
|
public string GuildId { get; set; } = "";
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||||
|
|
||||||
foreach (var channel in guildChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||||
|
|
||||||
foreach (var channel in dmChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
{
|
{
|
||||||
public ValueTask ExecuteAsync(IConsole console)
|
public ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get user token:"));
|
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||||
|
console.Output.WriteLine("To get user token:")
|
||||||
|
);
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||||
console.Output.WriteLine(" 3. Navigate to the Application tab");
|
console.Output.WriteLine(" 3. Navigate to the Application tab");
|
||||||
@@ -20,14 +22,18 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get bot token:"));
|
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||||
|
console.Output.WriteLine("To get bot token:")
|
||||||
|
);
|
||||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||||
console.Output.WriteLine(" 2. Open your application's settings");
|
console.Output.WriteLine(" 2. Open your application's settings");
|
||||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get guild ID or guild channel ID:"));
|
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||||
|
console.Output.WriteLine("To get guild ID or guild channel ID:")
|
||||||
|
);
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Open Settings");
|
console.Output.WriteLine(" 2. Open Settings");
|
||||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
console.Output.WriteLine(" 3. Go to Appearance section");
|
||||||
@@ -35,7 +41,9 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
console.WithForegroundColor(ConsoleColor.White, () => console.Output.WriteLine("To get direct message channel ID:"));
|
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||||
|
console.Output.WriteLine("To get direct message channel ID:")
|
||||||
|
);
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Open the desired direct message channel");
|
console.Output.WriteLine(" 2. Open the desired direct message channel");
|
||||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||||
@@ -44,8 +52,12 @@ namespace DiscordChatExporter.Cli.Commands
|
|||||||
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
console.Output.WriteLine("For more information, check out the wiki:");
|
console.WithForegroundColor(ConsoleColor.White,
|
||||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
() => console.Output.WriteLine("For more information, check out the wiki:")
|
||||||
|
);
|
||||||
|
console.WithForegroundColor(ConsoleColor.Blue,
|
||||||
|
() => console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki")
|
||||||
|
);
|
||||||
|
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="1.3.2" />
|
<PackageReference Include="CliFx" Version="1.5.0" />
|
||||||
<PackageReference Include="Gress" Version="1.2.0" />
|
<PackageReference Include="Gress" Version="1.2.0" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -9,50 +9,33 @@ using DiscordChatExporter.Domain.Discord.Models;
|
|||||||
using DiscordChatExporter.Domain.Exceptions;
|
using DiscordChatExporter.Domain.Exceptions;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal;
|
||||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
using Polly;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Discord
|
namespace DiscordChatExporter.Domain.Discord
|
||||||
{
|
{
|
||||||
public class DiscordClient
|
public class DiscordClient
|
||||||
{
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
private readonly AuthToken _token;
|
private readonly AuthToken _token;
|
||||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
|
||||||
private readonly IAsyncPolicy<HttpResponseMessage> _httpRequestPolicy;
|
|
||||||
|
|
||||||
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
|
private readonly Uri _baseUri = new Uri("https://discord.com/api/v6/", UriKind.Absolute);
|
||||||
|
|
||||||
public DiscordClient(AuthToken token)
|
public DiscordClient(HttpClient httpClient, AuthToken token)
|
||||||
{
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
_token = token;
|
_token = token;
|
||||||
|
|
||||||
// Discord seems to always respond with 429 on the first request with unreasonable wait time (10+ minutes).
|
|
||||||
// For that reason the policy will ignore such errors at first, then wait a constant amount of time, and
|
|
||||||
// finally wait the specified amount of time, based on how many requests have failed in a row.
|
|
||||||
_httpRequestPolicy = Policy
|
|
||||||
.HandleResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
|
||||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
|
||||||
.WaitAndRetryAsync(6,
|
|
||||||
(i, result, ctx) =>
|
|
||||||
{
|
|
||||||
if (i <= 3)
|
|
||||||
return TimeSpan.FromSeconds(2 * i);
|
|
||||||
|
|
||||||
if (i <= 5)
|
|
||||||
return TimeSpan.FromSeconds(5 * i);
|
|
||||||
|
|
||||||
return result.Result.Headers.RetryAfter.Delta ?? TimeSpan.FromSeconds(10 * i);
|
|
||||||
},
|
|
||||||
(response, timespan, retryCount, context) => Task.CompletedTask
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) => await _httpRequestPolicy.ExecuteAsync(async () =>
|
public DiscordClient(AuthToken token)
|
||||||
{
|
: this(Http.Client, token) {}
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
|
||||||
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
|
||||||
|
|
||||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
|
||||||
});
|
await Http.ResponsePolicy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
|
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
||||||
|
|
||||||
|
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
});
|
||||||
|
|
||||||
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
|
private async ValueTask<JsonElement> GetJsonResponseAsync(string url)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
@@ -20,11 +20,14 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
|
|
||||||
public int? Height { get; }
|
public int? Height { get; }
|
||||||
|
|
||||||
public bool IsImage =>
|
public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName));
|
||||||
ImageFileExtensions.Contains(Path.GetExtension(FileName), StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
public bool IsVideo => VideoFileExtensions.Contains(Path.GetExtension(FileName));
|
||||||
|
|
||||||
|
public bool IsAudio => AudioFileExtensions.Contains(Path.GetExtension(FileName));
|
||||||
|
|
||||||
public bool IsSpoiler =>
|
public bool IsSpoiler =>
|
||||||
IsImage && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
(IsImage || IsVideo || IsAudio) && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||||
|
|
||||||
public FileSize FileSize { get; }
|
public FileSize FileSize { get; }
|
||||||
|
|
||||||
@@ -43,7 +46,17 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
|
|
||||||
public partial class Attachment
|
public partial class Attachment
|
||||||
{
|
{
|
||||||
private static readonly string[] ImageFileExtensions = {".jpg", ".jpeg", ".png", ".gif", ".bmp"};
|
private static readonly HashSet<string> ImageFileExtensions =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> VideoFileExtensions =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{".mp4", ".webm"};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> AudioFileExtensions =
|
||||||
|
new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{".mp3", ".wav", ".ogg", ".flac", ".m4a"};
|
||||||
|
|
||||||
public static Attachment Parse(JsonElement json)
|
public static Attachment Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
|
|
||||||
public DateTimeOffset? EditedTimestamp { get; }
|
public DateTimeOffset? EditedTimestamp { get; }
|
||||||
|
|
||||||
|
public DateTimeOffset? CallEndedTimestamp { get; }
|
||||||
|
|
||||||
public bool IsPinned { get; }
|
public bool IsPinned { get; }
|
||||||
|
|
||||||
public string Content { get; }
|
public string Content { get; }
|
||||||
@@ -51,6 +53,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
User author,
|
User author,
|
||||||
DateTimeOffset timestamp,
|
DateTimeOffset timestamp,
|
||||||
DateTimeOffset? editedTimestamp,
|
DateTimeOffset? editedTimestamp,
|
||||||
|
DateTimeOffset? callEndedTimestamp,
|
||||||
bool isPinned,
|
bool isPinned,
|
||||||
string content,
|
string content,
|
||||||
IReadOnlyList<Attachment> attachments,
|
IReadOnlyList<Attachment> attachments,
|
||||||
@@ -63,6 +66,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
Author = author;
|
Author = author;
|
||||||
Timestamp = timestamp;
|
Timestamp = timestamp;
|
||||||
EditedTimestamp = editedTimestamp;
|
EditedTimestamp = editedTimestamp;
|
||||||
|
CallEndedTimestamp = callEndedTimestamp;
|
||||||
IsPinned = isPinned;
|
IsPinned = isPinned;
|
||||||
Content = content;
|
Content = content;
|
||||||
Attachments = attachments;
|
Attachments = attachments;
|
||||||
@@ -82,6 +86,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
var author = json.GetProperty("author").Pipe(User.Parse);
|
var author = json.GetProperty("author").Pipe(User.Parse);
|
||||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||||
|
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")?.GetDateTimeOffset();
|
||||||
var type = (MessageType) json.GetProperty("type").GetInt32();
|
var type = (MessageType) json.GetProperty("type").GetInt32();
|
||||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
||||||
|
|
||||||
@@ -89,7 +94,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
{
|
{
|
||||||
MessageType.RecipientAdd => "Added a recipient.",
|
MessageType.RecipientAdd => "Added a recipient.",
|
||||||
MessageType.RecipientRemove => "Removed a recipient.",
|
MessageType.RecipientRemove => "Removed a recipient.",
|
||||||
MessageType.Call => "Started a call.",
|
MessageType.Call =>
|
||||||
|
$"Started a call that lasted {callEndedTimestamp?.Pipe(t => t - timestamp).Pipe(t => (int) t.TotalMinutes) ?? 0} minutes.",
|
||||||
MessageType.ChannelNameChange => "Changed the channel name.",
|
MessageType.ChannelNameChange => "Changed the channel name.",
|
||||||
MessageType.ChannelIconChange => "Changed the channel icon.",
|
MessageType.ChannelIconChange => "Changed the channel icon.",
|
||||||
MessageType.ChannelPinnedMessage => "Pinned a message.",
|
MessageType.ChannelPinnedMessage => "Pinned a message.",
|
||||||
@@ -119,6 +125,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
|||||||
author,
|
author,
|
||||||
timestamp,
|
timestamp,
|
||||||
editedTimestamp,
|
editedTimestamp,
|
||||||
|
callEndedTimestamp,
|
||||||
isPinned,
|
isPinned,
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
|
|||||||
@@ -34,10 +34,15 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
Channels = channels;
|
Channels = channels;
|
||||||
Roles = roles;
|
Roles = roles;
|
||||||
|
|
||||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath);
|
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string FormatDate(DateTimeOffset date) => date.ToLocalString(Request.DateFormat);
|
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
||||||
|
{
|
||||||
|
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||||
|
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||||
|
var dateFormat => date.ToLocalString(dateFormat)
|
||||||
|
};
|
||||||
|
|
||||||
public Member? TryGetMember(string id) =>
|
public Member? TryGetMember(string id) =>
|
||||||
Members.FirstOrDefault(m => m.Id == id);
|
Members.FirstOrDefault(m => m.Id == id);
|
||||||
@@ -69,10 +74,13 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
{
|
{
|
||||||
var filePath = await _mediaDownloader.DownloadAsync(url);
|
var filePath = await _mediaDownloader.DownloadAsync(url);
|
||||||
|
|
||||||
// We want relative path so that the output files can be copied around without breaking
|
// We want relative path so that the output files can be copied around without breaking.
|
||||||
var relativeFilePath = Path.GetRelativePath(Request.OutputBaseDirPath, filePath);
|
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
||||||
|
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath)
|
||||||
|
? Path.GetRelativePath(Request.OutputBaseDirPath, filePath)
|
||||||
|
: filePath;
|
||||||
|
|
||||||
// For HTML, we need to format the URL properly
|
// HACK: for HTML, we need to format the URL properly
|
||||||
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
|
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
|
||||||
{
|
{
|
||||||
// Need to escape each path segment while keeping the directory separators intact
|
// Need to escape each path segment while keeping the directory separators intact
|
||||||
@@ -84,8 +92,12 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
return relativeFilePath;
|
return relativeFilePath;
|
||||||
}
|
}
|
||||||
catch (HttpRequestException)
|
// Try to catch only exceptions related to failed HTTP requests
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
||||||
|
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
// TODO: add logging so we can be more liberal with catching exceptions
|
||||||
// We don't want this to crash the exporting process in case of failure
|
// We don't want this to crash the exporting process in case of failure
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
public bool ShouldDownloadMedia { get; }
|
public bool ShouldDownloadMedia { get; }
|
||||||
|
|
||||||
|
public bool ShouldReuseMedia { get; }
|
||||||
|
|
||||||
public string DateFormat { get; }
|
public string DateFormat { get; }
|
||||||
|
|
||||||
public ExportRequest(
|
public ExportRequest(
|
||||||
@@ -41,6 +43,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
DateTimeOffset? before,
|
DateTimeOffset? before,
|
||||||
int? partitionLimit,
|
int? partitionLimit,
|
||||||
bool shouldDownloadMedia,
|
bool shouldDownloadMedia,
|
||||||
|
bool shouldReuseMedia,
|
||||||
string dateFormat)
|
string dateFormat)
|
||||||
{
|
{
|
||||||
Guild = guild;
|
Guild = guild;
|
||||||
@@ -51,6 +54,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
Before = before;
|
Before = before;
|
||||||
PartitionLimit = partitionLimit;
|
PartitionLimit = partitionLimit;
|
||||||
ShouldDownloadMedia = shouldDownloadMedia;
|
ShouldDownloadMedia = shouldDownloadMedia;
|
||||||
|
ShouldReuseMedia = shouldReuseMedia;
|
||||||
DateFormat = dateFormat;
|
DateFormat = dateFormat;
|
||||||
|
|
||||||
OutputBaseFilePath = GetOutputBaseFilePath(
|
OutputBaseFilePath = GetOutputBaseFilePath(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Domain.Internal;
|
using DiscordChatExporter.Domain.Internal;
|
||||||
@@ -11,45 +13,74 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
{
|
{
|
||||||
internal partial class MediaDownloader
|
internal partial class MediaDownloader
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly string _workingDirPath;
|
private readonly string _workingDirPath;
|
||||||
|
private readonly bool _reuseMedia;
|
||||||
|
|
||||||
private readonly Dictionary<string, string> _pathMap = new Dictionary<string, string>();
|
// URL -> Local file path
|
||||||
|
private readonly Dictionary<string, string> _pathCache =
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
public MediaDownloader(string workingDirPath)
|
public MediaDownloader(HttpClient httpClient, string workingDirPath, bool reuseMedia)
|
||||||
{
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
_workingDirPath = workingDirPath;
|
_workingDirPath = workingDirPath;
|
||||||
|
_reuseMedia = reuseMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public MediaDownloader(string workingDirPath, bool reuseMedia)
|
||||||
|
: this(Http.Client, workingDirPath, reuseMedia) {}
|
||||||
|
|
||||||
public async ValueTask<string> DownloadAsync(string url)
|
public async ValueTask<string> DownloadAsync(string url)
|
||||||
{
|
{
|
||||||
if (_pathMap.TryGetValue(url, out var cachedFilePath))
|
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||||
return cachedFilePath;
|
return cachedFilePath;
|
||||||
|
|
||||||
var fileName = GetFileNameFromUrl(url);
|
var fileName = GetFileNameFromUrl(url);
|
||||||
var filePath = PathEx.MakeUniqueFilePath(Path.Combine(_workingDirPath, fileName));
|
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||||
|
|
||||||
|
// Reuse existing files if we're allowed to
|
||||||
|
if (_reuseMedia && File.Exists(filePath))
|
||||||
|
return _pathCache[url] = filePath;
|
||||||
|
|
||||||
|
// Download it
|
||||||
Directory.CreateDirectory(_workingDirPath);
|
Directory.CreateDirectory(_workingDirPath);
|
||||||
|
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
// This catches IOExceptions which is dangerous as we're working also with files
|
||||||
|
await _httpClient.DownloadAsync(url, filePath);
|
||||||
|
});
|
||||||
|
|
||||||
await _httpClient.DownloadAsync(url, filePath);
|
return _pathCache[url] = filePath;
|
||||||
|
|
||||||
return _pathMap[url] = filePath;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MediaDownloader
|
internal partial class MediaDownloader
|
||||||
{
|
{
|
||||||
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
|
private static string GetUrlHash(string url)
|
||||||
|
{
|
||||||
|
using var hash = SHA256.Create();
|
||||||
|
|
||||||
|
var data = hash.ComputeHash(Encoding.UTF8.GetBytes(url));
|
||||||
|
return data.ToHex().Truncate(5); // 5 chars ought to be enough for anybody
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetFileNameFromUrl(string url)
|
private static string GetFileNameFromUrl(string url)
|
||||||
{
|
{
|
||||||
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
var urlHash = GetUrlHash(url);
|
||||||
|
|
||||||
var fileName = !string.IsNullOrWhiteSpace(originalFileName)
|
// Try to extract file name from URL
|
||||||
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(50)}{Path.GetExtension(originalFileName)}"
|
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||||
: GetRandomFileName();
|
|
||||||
|
|
||||||
return PathEx.EscapePath(fileName);
|
// If it's not there, just use the URL hash as the file name
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
return urlHash;
|
||||||
|
|
||||||
|
// Otherwise, use the original file name but inject the hash in the middle
|
||||||
|
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
var fileExtension = Path.GetExtension(fileName);
|
||||||
|
|
||||||
|
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
|
|
||||||
internal partial class MessageExporter
|
internal partial class MessageExporter
|
||||||
{
|
{
|
||||||
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
|
private static string GetPartitionFilePath(
|
||||||
|
string baseFilePath,
|
||||||
|
int partitionIndex)
|
||||||
{
|
{
|
||||||
// First partition - don't change file name
|
// First partition - don't change file name
|
||||||
if (partitionIndex <= 0)
|
if (partitionIndex <= 0)
|
||||||
@@ -82,16 +84,17 @@ namespace DiscordChatExporter.Domain.Exporting
|
|||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||||
var fileExt = Path.GetExtension(baseFilePath);
|
var fileExt = Path.GetExtension(baseFilePath);
|
||||||
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
||||||
|
|
||||||
// Generate new path
|
|
||||||
var dirPath = Path.GetDirectoryName(baseFilePath);
|
var dirPath = Path.GetDirectoryName(baseFilePath);
|
||||||
if (!string.IsNullOrWhiteSpace(dirPath))
|
|
||||||
return Path.Combine(dirPath, fileName);
|
|
||||||
|
|
||||||
return fileName;
|
return !string.IsNullOrWhiteSpace(dirPath)
|
||||||
|
? Path.Combine(dirPath, fileName)
|
||||||
|
: fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MessageWriter CreateMessageWriter(string filePath, ExportFormat format, ExportContext context)
|
private static MessageWriter CreateMessageWriter(
|
||||||
|
string filePath,
|
||||||
|
ExportFormat format,
|
||||||
|
ExportContext context)
|
||||||
{
|
{
|
||||||
// Stream will be disposed by the underlying writer
|
// Stream will be disposed by the underlying writer
|
||||||
var stream = File.Create(filePath);
|
var stream = File.Create(filePath);
|
||||||
|
|||||||
@@ -2,31 +2,31 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://discord.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
|
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +49,18 @@ img {
|
|||||||
|
|
||||||
.markdown {
|
.markdown {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
white-space: pre-wrap;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preserve-whitespace {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.spoiler {
|
.spoiler {
|
||||||
width: fit-content;
|
/* width: fit-content; */
|
||||||
|
display: inline-block;
|
||||||
|
/* This is more consistent across browsers, the old attribute worked well under Chrome but not FireFox. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.spoiler--hidden {
|
.spoiler--hidden {
|
||||||
@@ -84,24 +89,28 @@ img {
|
|||||||
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spoiler--hidden .spoiler-image img {
|
.spoiler--hidden .spoiler-image * {
|
||||||
filter: blur(44px);
|
filter: blur(44px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.spoiler--hidden .spoiler-image:after {
|
.spoiler--hidden .spoiler-image:after {
|
||||||
content: "SPOILER";
|
content: "SPOILER";
|
||||||
color: #dcddde;
|
color: #dcddde;
|
||||||
background-color: rgba(0, 0, 0, 0.6);
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 0.5em 0.7em;
|
/* padding: 0.5em 0.7em; */
|
||||||
border-radius: 20px;
|
padding: 100%;
|
||||||
letter-spacing: 0.05em;
|
/* This ruins those beutifully rounded buttons, but it's needed to prevent a FireFox bug with video and audio elemnts. */
|
||||||
font-size: 0.9em;
|
/* The bug is that you can click trough the spoiler layer and play the video or audio, I could not identify the cause. */
|
||||||
}
|
/* I leave this here, in case someone is brave enough to venture in to madness that is undocumented browser behaviour. */
|
||||||
|
border-radius: 20px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
.spoiler--hidden:hover .spoiler-image:after {
|
.spoiler--hidden:hover .spoiler-image:after {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
||||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
||||||
var userNick = (Model.MessageGroup.Author.IsBot ? Model.MessageGroup.Author.Name : (userMember?.Nick ?? Model.MessageGroup.Author.Name));
|
var userNick = Model.MessageGroup.Author.IsBot ? Model.MessageGroup.Author.Name : userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
||||||
|
|
||||||
var userColorStyle = userColor != null
|
var userColorStyle = userColor != null
|
||||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
||||||
@@ -40,36 +40,51 @@
|
|||||||
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
|
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
|
||||||
|
|
||||||
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
|
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
|
||||||
<div class="chatlog__content">
|
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp != null)
|
||||||
<div class="markdown">@Raw(FormatMarkdown(message.Content)) @if (message.EditedTimestamp != null) {<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>}</div>
|
{
|
||||||
</div>
|
<div class="chatlog__content">
|
||||||
|
<div class="markdown">
|
||||||
|
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
|
||||||
|
|
||||||
|
@if (message.EditedTimestamp != null)
|
||||||
|
{
|
||||||
|
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@foreach (var attachment in message.Attachments)
|
@foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
<div class="chatlog__attachment">
|
<div class="chatlog__attachment">
|
||||||
@if (attachment.IsSpoiler)
|
<div class="@(attachment.IsSpoiler ? "spoiler spoiler--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
|
||||||
{
|
<div class="@(attachment.IsSpoiler ? "spoiler-image" : "")">
|
||||||
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
|
|
||||||
<div class="spoiler-image">
|
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
|
||||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
|
||||||
@if (attachment.IsImage)
|
@if (attachment.IsImage)
|
||||||
{
|
{
|
||||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
|
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")">
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (attachment.IsVideo)
|
||||||
|
{
|
||||||
|
<video controls class="chatlog__attachment-thumbnail">
|
||||||
|
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else if (attachment.IsAudio)
|
||||||
|
{
|
||||||
|
<audio controls class="chatlog__attachment-thumbnail">
|
||||||
|
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
|
||||||
|
</audio>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
|
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
|
||||||
|
</a>
|
||||||
}
|
}
|
||||||
</a>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,12 +134,12 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -132,7 +147,7 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-description">
|
<div class="chatlog__embed-description">
|
||||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +162,14 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-name">
|
<div class="chatlog__embed-field-name">
|
||||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-value">
|
<div class="chatlog__embed-field-value">
|
||||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -190,22 +205,22 @@
|
|||||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
|
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
|
||||||
}
|
}
|
||||||
|
|
||||||
<span class="chatlog__embed-footer-text">
|
<span class="chatlog__embed-footer-text">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||||
{
|
{
|
||||||
@embed.Footer.Text
|
@embed.Footer.Text
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
||||||
{
|
{
|
||||||
@(" • ")
|
@(" • ")
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (embed.Timestamp != null)
|
@if (embed.Timestamp != null)
|
||||||
{
|
{
|
||||||
@FormatDate(embed.Timestamp.Value)
|
@FormatDate(embed.Timestamp.Value)
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using DiscordChatExporter.Domain.Discord.Models;
|
using DiscordChatExporter.Domain.Discord.Models;
|
||||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||||
|
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||||
{
|
{
|
||||||
@@ -114,6 +115,9 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteString("timestamp", embed.Timestamp);
|
_writer.WriteString("timestamp", embed.Timestamp);
|
||||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
||||||
|
|
||||||
|
if (embed.Color != null)
|
||||||
|
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||||
|
|
||||||
if (embed.Author != null)
|
if (embed.Author != null)
|
||||||
await WriteEmbedAuthorAsync(embed.Author);
|
await WriteEmbedAuthorAsync(embed.Author);
|
||||||
|
|
||||||
@@ -156,6 +160,20 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
await _writer.FlushAsync();
|
await _writer.FlushAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteMentionAsync(User mentionedUser)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", mentionedUser.Id);
|
||||||
|
_writer.WriteString("name", mentionedUser.Name);
|
||||||
|
_writer.WriteNumber("discriminator", mentionedUser.Discriminator);
|
||||||
|
_writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name);
|
||||||
|
_writer.WriteBoolean("isBot", mentionedUser.IsBot);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync()
|
public override async ValueTask WritePreambleAsync()
|
||||||
{
|
{
|
||||||
// Root object (start)
|
// Root object (start)
|
||||||
@@ -197,6 +215,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
_writer.WriteString("type", message.Type.ToString());
|
_writer.WriteString("type", message.Type.ToString());
|
||||||
_writer.WriteString("timestamp", message.Timestamp);
|
_writer.WriteString("timestamp", message.Timestamp);
|
||||||
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
||||||
|
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
|
||||||
_writer.WriteBoolean("isPinned", message.IsPinned);
|
_writer.WriteBoolean("isPinned", message.IsPinned);
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
@@ -235,6 +254,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
|||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Mentions
|
||||||
|
_writer.WriteStartArray("mentions");
|
||||||
|
foreach (var mention in message.MentionedUsers)
|
||||||
|
await WriteMentionAsync(mention);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
await _writer.FlushAsync();
|
await _writer.FlushAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||||
|
{
|
||||||
|
internal static class BinaryExtensions
|
||||||
|
{
|
||||||
|
public static string ToHex(this byte[] data)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder();
|
||||||
|
|
||||||
|
foreach (var t in data)
|
||||||
|
{
|
||||||
|
buffer.Append(t.ToString("X2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,5 +9,7 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
|
|||||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||||
|
|
||||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||||
|
|
||||||
|
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Polly;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Domain.Internal
|
||||||
|
{
|
||||||
|
internal static class Http
|
||||||
|
{
|
||||||
|
public static HttpClient Client { get; } = new HttpClient();
|
||||||
|
|
||||||
|
public static IAsyncPolicy<HttpResponseMessage> ResponsePolicy { get; } =
|
||||||
|
Policy
|
||||||
|
.Handle<IOException>()
|
||||||
|
.Or<HttpRequestException>()
|
||||||
|
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
||||||
|
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
|
||||||
|
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
||||||
|
.WaitAndRetryAsync(8,
|
||||||
|
(i, result, ctx) =>
|
||||||
|
{
|
||||||
|
// If rate-limited, use retry-after as a guide
|
||||||
|
if (result.Result.StatusCode == HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
// Only start respecting retry-after after a few attempts.
|
||||||
|
// The reason is that Discord often sends unreasonable (20+ minutes) retry-after
|
||||||
|
// on the very first request.
|
||||||
|
if (i > 3)
|
||||||
|
{
|
||||||
|
var retryAfterDelay = result.Result.Headers.RetryAfter.Delta;
|
||||||
|
if (retryAfterDelay != null)
|
||||||
|
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
|
||||||
|
},
|
||||||
|
(response, timespan, retryCount, context) => Task.CompletedTask);
|
||||||
|
|
||||||
|
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex)
|
||||||
|
{
|
||||||
|
// This is extremely frail, but there's no other way
|
||||||
|
var statusCodeRaw = Regex.Match(ex.Message, @": (\d+) \(").Groups[1].Value;
|
||||||
|
return !string.IsNullOrWhiteSpace(statusCodeRaw)
|
||||||
|
? (HttpStatusCode) int.Parse(statusCodeRaw, CultureInfo.InvariantCulture)
|
||||||
|
: (HttpStatusCode?) null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IAsyncPolicy ExceptionPolicy { get; } =
|
||||||
|
Policy
|
||||||
|
.Handle<IOException>() // dangerous
|
||||||
|
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.TooManyRequests)
|
||||||
|
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) == HttpStatusCode.RequestTimeout)
|
||||||
|
.Or<HttpRequestException>(ex => TryGetStatusCodeFromException(ex) >= HttpStatusCode.InternalServerError)
|
||||||
|
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,27 +14,5 @@ namespace DiscordChatExporter.Domain.Internal
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
||||||
|
|
||||||
public static string MakeUniqueFilePath(string baseFilePath, int maxAttempts = int.MaxValue)
|
|
||||||
{
|
|
||||||
if (!File.Exists(baseFilePath))
|
|
||||||
return baseFilePath;
|
|
||||||
|
|
||||||
var baseDirPath = Path.GetDirectoryName(baseFilePath);
|
|
||||||
var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath);
|
|
||||||
var baseFileExtension = Path.GetExtension(baseFilePath);
|
|
||||||
|
|
||||||
for (var i = 1; i <= maxAttempts; i++)
|
|
||||||
{
|
|
||||||
var filePath = $"{baseFileNameWithoutExtension} ({i}){baseFileExtension}";
|
|
||||||
if (!string.IsNullOrWhiteSpace(baseDirPath))
|
|
||||||
filePath = Path.Combine(baseDirPath, filePath);
|
|
||||||
|
|
||||||
if (!File.Exists(filePath))
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseFilePath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Net;
|
|
||||||
using System.Net.Http;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Domain.Internal
|
|
||||||
{
|
|
||||||
internal static class Singleton
|
|
||||||
{
|
|
||||||
private static readonly Lazy<HttpClient> LazyHttpClient = new Lazy<HttpClient>(() =>
|
|
||||||
{
|
|
||||||
var handler = new HttpClientHandler();
|
|
||||||
|
|
||||||
if (handler.SupportsAutomaticDecompression)
|
|
||||||
handler.AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate;
|
|
||||||
|
|
||||||
handler.UseCookies = false;
|
|
||||||
|
|
||||||
return new HttpClient(handler, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
public static HttpClient HttpClient { get; } = LazyHttpClient.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,8 @@ namespace DiscordChatExporter.Domain.Utilities
|
|||||||
{
|
{
|
||||||
public static class AsyncExtensions
|
public static class AsyncExtensions
|
||||||
{
|
{
|
||||||
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(this IAsyncEnumerable<T> asyncEnumerable)
|
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>(
|
||||||
|
this IAsyncEnumerable<T> asyncEnumerable)
|
||||||
{
|
{
|
||||||
var list = new List<T>();
|
var list = new List<T>();
|
||||||
|
|
||||||
@@ -19,10 +20,14 @@ namespace DiscordChatExporter.Domain.Utilities
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(this IAsyncEnumerable<T> asyncEnumerable) =>
|
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||||
|
this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||||
asyncEnumerable.AggregateAsync().GetAwaiter();
|
asyncEnumerable.AggregateAsync().GetAwaiter();
|
||||||
|
|
||||||
public static async ValueTask ParallelForEachAsync<T>(this IEnumerable<T> source, Func<T, Task> handleAsync, int degreeOfParallelism)
|
public static async ValueTask ParallelForEachAsync<T>(
|
||||||
|
this IEnumerable<T> source,
|
||||||
|
Func<T, ValueTask> handleAsync,
|
||||||
|
int degreeOfParallelism)
|
||||||
{
|
{
|
||||||
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
using var semaphore = new SemaphoreSlim(degreeOfParallelism);
|
||||||
|
|
||||||
|
|||||||
@@ -14,69 +14,15 @@
|
|||||||
|
|
||||||
<!-- Merged dictionaries -->
|
<!-- Merged dictionaries -->
|
||||||
<ResourceDictionary.MergedDictionaries>
|
<ResourceDictionary.MergedDictionaries>
|
||||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Light.xaml" />
|
<!-- Colors are irrelevant because they are overriden in runtime -->
|
||||||
|
<materialDesign:BundledTheme
|
||||||
|
BaseTheme="Light"
|
||||||
|
PrimaryColor="Blue"
|
||||||
|
SecondaryColor="Blue" />
|
||||||
|
|
||||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
||||||
</ResourceDictionary.MergedDictionaries>
|
</ResourceDictionary.MergedDictionaries>
|
||||||
|
|
||||||
<!-- Colors -->
|
|
||||||
<Color x:Key="PrimaryColor">#343838</Color>
|
|
||||||
<Color x:Key="PrimaryLightColor">#5E6262</Color>
|
|
||||||
<Color x:Key="PrimaryDarkColor">#0D1212</Color>
|
|
||||||
<Color x:Key="AccentColor">#F9A825</Color>
|
|
||||||
<Color x:Key="AccentDarkColor">#C17900</Color>
|
|
||||||
<Color x:Key="TextColor">#000000</Color>
|
|
||||||
<Color x:Key="InverseTextColor">#FFFFFF</Color>
|
|
||||||
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{DynamicResource PrimaryLightColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{DynamicResource PrimaryColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{DynamicResource PrimaryDarkColor}" />
|
|
||||||
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush x:Key="SecondaryAccentBrush" Color="{DynamicResource AccentColor}" />
|
|
||||||
<SolidColorBrush x:Key="SecondaryAccentForegroundBrush" Color="{DynamicResource TextColor}" />
|
|
||||||
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="PrimaryTextBrush"
|
|
||||||
Opacity="0.87"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="SecondaryTextBrush"
|
|
||||||
Opacity="0.64"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="DimTextBrush"
|
|
||||||
Opacity="0.45"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="PrimaryInverseTextBrush"
|
|
||||||
Opacity="1"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="SecondaryInverseTextBrush"
|
|
||||||
Opacity="0.7"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="DimInverseTextBrush"
|
|
||||||
Opacity="0.52"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="AccentTextBrush"
|
|
||||||
Opacity="1"
|
|
||||||
Color="{DynamicResource AccentColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="AccentDarkTextBrush"
|
|
||||||
Opacity="1"
|
|
||||||
Color="{DynamicResource AccentDarkColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="DividerBrush"
|
|
||||||
Opacity="0.12"
|
|
||||||
Color="{DynamicResource TextColor}" />
|
|
||||||
<SolidColorBrush
|
|
||||||
x:Key="InverseDividerBrush"
|
|
||||||
Opacity="0.12"
|
|
||||||
Color="{DynamicResource InverseTextColor}" />
|
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
|
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
|
||||||
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
|
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
|
||||||
@@ -84,7 +30,7 @@
|
|||||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||||
<Setter Property="TextElement.FontSize" Value="13" />
|
<Setter Property="TextElement.FontSize" Value="13" />
|
||||||
<Setter Property="TextElement.FontWeight" Value="Regular" />
|
<Setter Property="TextElement.FontWeight" Value="Regular" />
|
||||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource SecondaryTextBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
|
||||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
||||||
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
|
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
|
||||||
<Setter Property="UseLayoutRounding" Value="True" />
|
<Setter Property="UseLayoutRounding" Value="True" />
|
||||||
@@ -94,27 +40,20 @@
|
|||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryAccentBrush}" />
|
||||||
<Setter Property="Height" Value="2" />
|
<Setter Property="Height" Value="2" />
|
||||||
<Setter Property="Maximum" Value="1" />
|
<Setter Property="Maximum" Value="1" />
|
||||||
<Setter Property="Minimum" Value="0" />
|
<Setter Property="Minimum" Value="0" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}">
|
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}">
|
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}">
|
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}">
|
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
|
||||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<Style
|
<Style
|
||||||
x:Key="MaterialDesignFlatDarkButton"
|
x:Key="MaterialDesignFlatDarkButton"
|
||||||
|
|||||||
@@ -1,16 +1,49 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using DiscordChatExporter.Gui.Internal;
|
||||||
|
using MaterialDesignThemes.Wpf;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui
|
namespace DiscordChatExporter.Gui
|
||||||
{
|
{
|
||||||
public partial class App
|
public partial class App
|
||||||
{
|
{
|
||||||
private static readonly Assembly Assembly = typeof(App).Assembly;
|
private static Assembly Assembly { get; } = typeof(App).Assembly;
|
||||||
|
|
||||||
public static string Name => Assembly.GetName().Name!;
|
public static string Name { get; } = Assembly.GetName().Name!;
|
||||||
|
|
||||||
public static Version Version => Assembly.GetName().Version!;
|
public static Version Version { get; } = Assembly.GetName().Version!;
|
||||||
|
|
||||||
public static string VersionString => Version.ToString(3);
|
public static string VersionString { get; } = Version.ToString(3);
|
||||||
|
|
||||||
|
public static string GitHubProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
|
||||||
|
|
||||||
|
public static string GitHubProjectWikiUrl { get; } = GitHubProjectUrl + "/wiki";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class App
|
||||||
|
{
|
||||||
|
private static Theme LightTheme { get; } = Theme.Create(
|
||||||
|
new MaterialDesignLightTheme(),
|
||||||
|
MediaColor.FromHex("#343838"),
|
||||||
|
MediaColor.FromHex("#F9A825")
|
||||||
|
);
|
||||||
|
|
||||||
|
private static Theme DarkTheme { get; } = Theme.Create(
|
||||||
|
new MaterialDesignDarkTheme(),
|
||||||
|
MediaColor.FromHex("#E8E8E8"),
|
||||||
|
MediaColor.FromHex("#F9A825")
|
||||||
|
);
|
||||||
|
|
||||||
|
public static void SetLightTheme()
|
||||||
|
{
|
||||||
|
var paletteHelper = new PaletteHelper();
|
||||||
|
paletteHelper.SetTheme(LightTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetDarkTheme()
|
||||||
|
{
|
||||||
|
var paletteHelper = new PaletteHelper();
|
||||||
|
paletteHelper.SetTheme(DarkTheme);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,9 +31,9 @@ namespace DiscordChatExporter.Gui.Behaviors
|
|||||||
private bool _viewHandled;
|
private bool _viewHandled;
|
||||||
private bool _modelHandled;
|
private bool _modelHandled;
|
||||||
|
|
||||||
public IList SelectedItems
|
public IList? SelectedItems
|
||||||
{
|
{
|
||||||
get => (IList) GetValue(SelectedItemsProperty);
|
get => (IList?) GetValue(SelectedItemsProperty);
|
||||||
set => SetValue(SelectedItemsProperty, value);
|
set => SetValue(SelectedItemsProperty, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ namespace DiscordChatExporter.Gui
|
|||||||
{
|
{
|
||||||
public class Bootstrapper : Bootstrapper<RootViewModel>
|
public class Bootstrapper : Bootstrapper<RootViewModel>
|
||||||
{
|
{
|
||||||
|
protected override void OnStart()
|
||||||
|
{
|
||||||
|
base.OnStart();
|
||||||
|
|
||||||
|
// Set default theme
|
||||||
|
// (preferred theme will be chosen later, once the settings are loaded)
|
||||||
|
App.SetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
protected override void ConfigureIoC(IStyletIoCBuilder builder)
|
protected override void ConfigureIoC(IStyletIoCBuilder builder)
|
||||||
{
|
{
|
||||||
base.ConfigureIoC(builder);
|
base.ConfigureIoC(builder);
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ namespace DiscordChatExporter.Gui.Converters
|
|||||||
return default(string);
|
return default(string);
|
||||||
}
|
}
|
||||||
|
|
||||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
{
|
throw new NotSupportedException();
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
||||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
||||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
||||||
<PackageReference Include="Onova" Version="2.6.1" />
|
<PackageReference Include="Onova" Version="2.6.2" />
|
||||||
<PackageReference Include="Stylet" Version="1.3.4" />
|
<PackageReference Include="Stylet" Version="1.3.4" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||||
<PackageReference Include="PropertyChanged.Fody" Version="3.2.8" PrivateAssets="all" />
|
<PackageReference Include="PropertyChanged.Fody" Version="3.2.10" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
|
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
|
||||||
</xs:annotation>
|
</xs:annotation>
|
||||||
</xs:attribute>
|
</xs:attribute>
|
||||||
|
<xs:attribute name="TriggerDependentProperties" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Used to control if the Dependent properties feature is enabled.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
|
<xs:attribute name="EnableIsChangedProperty" type="xs:boolean">
|
||||||
|
<xs:annotation>
|
||||||
|
<xs:documentation>Used to control if the IsChanged property feature is enabled.</xs:documentation>
|
||||||
|
</xs:annotation>
|
||||||
|
</xs:attribute>
|
||||||
<xs:attribute name="EventInvokerNames" type="xs:string">
|
<xs:attribute name="EventInvokerNames" type="xs:string">
|
||||||
<xs:annotation>
|
<xs:annotation>
|
||||||
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
|
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Windows.Media;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Internal
|
||||||
|
{
|
||||||
|
internal static class MediaColor
|
||||||
|
{
|
||||||
|
public static Color FromHex(string hex) => (Color) ColorConverter.ConvertFromString(hex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,7 @@ namespace DiscordChatExporter.Gui.Internal
|
|||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
};
|
};
|
||||||
|
|
||||||
using (Process.Start(startInfo))
|
using (Process.Start(startInfo)) {}
|
||||||
{ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,14 +6,18 @@ namespace DiscordChatExporter.Gui.Services
|
|||||||
{
|
{
|
||||||
public class SettingsService : SettingsManager
|
public class SettingsService : SettingsManager
|
||||||
{
|
{
|
||||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
|
||||||
|
|
||||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
public bool IsDarkModeEnabled { get; set; }
|
||||||
|
|
||||||
public bool IsTokenPersisted { get; set; } = true;
|
public bool IsTokenPersisted { get; set; } = true;
|
||||||
|
|
||||||
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
|
||||||
public int ParallelLimit { get; set; } = 1;
|
public int ParallelLimit { get; set; } = 1;
|
||||||
|
|
||||||
|
public bool ShouldReuseMedia { get; set; }
|
||||||
|
|
||||||
public AuthToken? LastToken { get; set; }
|
public AuthToken? LastToken { get; set; }
|
||||||
|
|
||||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ namespace DiscordChatExporter.Gui.Services
|
|||||||
{
|
{
|
||||||
private readonly IUpdateManager _updateManager = new UpdateManager(
|
private readonly IUpdateManager _updateManager = new UpdateManager(
|
||||||
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||||
new ZipPackageExtractor());
|
new ZipPackageExtractor()
|
||||||
|
);
|
||||||
|
|
||||||
private readonly SettingsService _settingsService;
|
private readonly SettingsService _settingsService;
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,16 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||||
|
|
||||||
// If single channel - prompt file path
|
// If single channel - prompt file path
|
||||||
if (IsSingleChannel)
|
if (Channels != null && IsSingleChannel)
|
||||||
{
|
{
|
||||||
var channel = Channels.Single();
|
var channel = Channels.Single();
|
||||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(Guild!, channel, SelectedFormat, After, Before);
|
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
||||||
|
Guild!,
|
||||||
|
channel,
|
||||||
|
SelectedFormat,
|
||||||
|
After,
|
||||||
|
Before
|
||||||
|
);
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
var ext = SelectedFormat.GetFileExtension();
|
var ext = SelectedFormat.GetFileExtension();
|
||||||
@@ -92,11 +98,24 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
OutputPath = _dialogManager.PromptDirectoryPath();
|
OutputPath = _dialogManager.PromptDirectoryPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
// If canceled - return
|
|
||||||
if (string.IsNullOrWhiteSpace(OutputPath))
|
if (string.IsNullOrWhiteSpace(OutputPath))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
Close(true);
|
Close(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ExportSetupViewModelExtensions
|
||||||
|
{
|
||||||
|
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
|
||||||
|
Guild guild, IReadOnlyList<Channel> channels)
|
||||||
|
{
|
||||||
|
var viewModel = factory.CreateExportSetupViewModel();
|
||||||
|
|
||||||
|
viewModel.Guild = guild;
|
||||||
|
viewModel.Channels = channels;
|
||||||
|
|
||||||
|
return viewModel;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,29 +8,41 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
|||||||
{
|
{
|
||||||
private readonly SettingsService _settingsService;
|
private readonly SettingsService _settingsService;
|
||||||
|
|
||||||
public string DateFormat
|
|
||||||
{
|
|
||||||
get => _settingsService.DateFormat;
|
|
||||||
set => _settingsService.DateFormat = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsAutoUpdateEnabled
|
public bool IsAutoUpdateEnabled
|
||||||
{
|
{
|
||||||
get => _settingsService.IsAutoUpdateEnabled;
|
get => _settingsService.IsAutoUpdateEnabled;
|
||||||
set => _settingsService.IsAutoUpdateEnabled = value;
|
set => _settingsService.IsAutoUpdateEnabled = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsDarkModeEnabled
|
||||||
|
{
|
||||||
|
get => _settingsService.IsDarkModeEnabled;
|
||||||
|
set => _settingsService.IsDarkModeEnabled = value;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsTokenPersisted
|
public bool IsTokenPersisted
|
||||||
{
|
{
|
||||||
get => _settingsService.IsTokenPersisted;
|
get => _settingsService.IsTokenPersisted;
|
||||||
set => _settingsService.IsTokenPersisted = value;
|
set => _settingsService.IsTokenPersisted = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string DateFormat
|
||||||
|
{
|
||||||
|
get => _settingsService.DateFormat;
|
||||||
|
set => _settingsService.DateFormat = value;
|
||||||
|
}
|
||||||
|
|
||||||
public int ParallelLimit
|
public int ParallelLimit
|
||||||
{
|
{
|
||||||
get => _settingsService.ParallelLimit;
|
get => _settingsService.ParallelLimit;
|
||||||
set => _settingsService.ParallelLimit = value.Clamp(1, 10);
|
set => _settingsService.ParallelLimit = value.Clamp(1, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool ShouldReuseMedia
|
||||||
|
{
|
||||||
|
get => _settingsService.ShouldReuseMedia;
|
||||||
|
set => _settingsService.ShouldReuseMedia = value;
|
||||||
|
}
|
||||||
|
|
||||||
public SettingsViewModel(SettingsService settingsService)
|
public SettingsViewModel(SettingsService settingsService)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,13 +19,10 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
|||||||
|
|
||||||
public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
|
public async ValueTask<T> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
|
||||||
{
|
{
|
||||||
// Get the view that renders this viewmodel
|
|
||||||
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
||||||
|
|
||||||
// Set up event routing that will close the view when called from viewmodel
|
|
||||||
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
|
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
|
||||||
{
|
{
|
||||||
// Delegate to close the dialog and unregister event handler
|
|
||||||
void OnScreenClosed(object? o, EventArgs closeArgs)
|
void OnScreenClosed(object? o, EventArgs closeArgs)
|
||||||
{
|
{
|
||||||
openArgs.Session.Close();
|
openArgs.Session.Close();
|
||||||
@@ -35,37 +32,31 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
|||||||
dialogScreen.Closed += OnScreenClosed;
|
dialogScreen.Closed += OnScreenClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show view
|
|
||||||
await DialogHost.Show(view, OnDialogOpened);
|
await DialogHost.Show(view, OnDialogOpened);
|
||||||
|
|
||||||
// Return the result
|
|
||||||
return dialogScreen.DialogResult;
|
return dialogScreen.DialogResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
||||||
{
|
{
|
||||||
// Create dialog
|
|
||||||
var dialog = new SaveFileDialog
|
var dialog = new SaveFileDialog
|
||||||
{
|
{
|
||||||
Filter = filter,
|
Filter = filter,
|
||||||
AddExtension = true,
|
AddExtension = true,
|
||||||
FileName = defaultFilePath,
|
FileName = defaultFilePath,
|
||||||
DefaultExt = Path.GetExtension(defaultFilePath) ?? ""
|
DefaultExt = Path.GetExtension(defaultFilePath)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show dialog and return result
|
|
||||||
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
return dialog.ShowDialog() == true ? dialog.FileName : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? PromptDirectoryPath(string defaultDirPath = "")
|
public string? PromptDirectoryPath(string defaultDirPath = "")
|
||||||
{
|
{
|
||||||
// Create dialog
|
|
||||||
var dialog = new VistaFolderBrowserDialog
|
var dialog = new VistaFolderBrowserDialog
|
||||||
{
|
{
|
||||||
SelectedPath = defaultDirPath
|
SelectedPath = defaultDirPath
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show dialog and return result
|
|
||||||
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
|
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
|
|||||||
|
|
||||||
public void Close(T dialogResult = default)
|
public void Close(T dialogResult = default)
|
||||||
{
|
{
|
||||||
DialogResult = dialogResult;
|
DialogResult = dialogResult!;
|
||||||
Closed?.Invoke(this, EventArgs.Empty);
|
Closed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using DiscordChatExporter.Domain.Discord.Models;
|
|
||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels.Framework
|
|
||||||
{
|
|
||||||
public static class Extensions
|
|
||||||
{
|
|
||||||
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
|
|
||||||
Guild guild, IReadOnlyList<Channel> channels)
|
|
||||||
{
|
|
||||||
var viewModel = factory.CreateExportSetupViewModel();
|
|
||||||
viewModel.Guild = guild;
|
|
||||||
viewModel.Channels = channels;
|
|
||||||
|
|
||||||
return viewModel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,9 @@ using DiscordChatExporter.Domain.Discord.Models;
|
|||||||
using DiscordChatExporter.Domain.Exceptions;
|
using DiscordChatExporter.Domain.Exceptions;
|
||||||
using DiscordChatExporter.Domain.Exporting;
|
using DiscordChatExporter.Domain.Exporting;
|
||||||
using DiscordChatExporter.Domain.Utilities;
|
using DiscordChatExporter.Domain.Utilities;
|
||||||
|
using DiscordChatExporter.Gui.Internal;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
using Gress;
|
using Gress;
|
||||||
using MaterialDesignThemes.Wpf;
|
using MaterialDesignThemes.Wpf;
|
||||||
@@ -63,14 +65,21 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
|
|
||||||
// Update busy state when progress manager changes
|
// Update busy state when progress manager changes
|
||||||
ProgressManager.Bind(o => o.IsActive,
|
ProgressManager.Bind(o => o.IsActive,
|
||||||
(sender, args) => IsBusy = ProgressManager.IsActive);
|
(sender, args) => IsBusy = ProgressManager.IsActive
|
||||||
|
);
|
||||||
|
|
||||||
ProgressManager.Bind(o => o.IsActive,
|
ProgressManager.Bind(o => o.IsActive,
|
||||||
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
(sender, args) => IsProgressIndeterminate =
|
||||||
|
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
|
||||||
|
);
|
||||||
|
|
||||||
ProgressManager.Bind(o => o.Progress,
|
ProgressManager.Bind(o => o.Progress,
|
||||||
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
(sender, args) => IsProgressIndeterminate =
|
||||||
|
ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask HandleAutoUpdateAsync()
|
private async ValueTask CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -108,7 +117,16 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
TokenValue = _settingsService.LastToken.Value;
|
TokenValue = _settingsService.LastToken.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await HandleAutoUpdateAsync();
|
if (_settingsService.IsDarkModeEnabled)
|
||||||
|
{
|
||||||
|
App.SetDarkTheme();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
App.SetLightTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
await CheckForUpdatesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClose()
|
protected override void OnClose()
|
||||||
@@ -125,6 +143,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
await _dialogManager.ShowDialogAsync(dialog);
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
|
||||||
|
|
||||||
public bool CanPopulateGuildsAndChannels =>
|
public bool CanPopulateGuildsAndChannels =>
|
||||||
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
|
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
|
||||||
|
|
||||||
@@ -178,8 +198,8 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
var exporter = new ChannelExporter(token);
|
var exporter = new ChannelExporter(token);
|
||||||
|
|
||||||
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
|
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
|
||||||
|
|
||||||
var successfulExportCount = 0;
|
var successfulExportCount = 0;
|
||||||
|
|
||||||
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||||
{
|
{
|
||||||
var (channel, operation) = tuple;
|
var (channel, operation) = tuple;
|
||||||
@@ -195,6 +215,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
|||||||
dialog.Before,
|
dialog.Before,
|
||||||
dialog.PartitionLimit,
|
dialog.PartitionLimit,
|
||||||
dialog.ShouldDownloadMedia,
|
dialog.ShouldDownloadMedia,
|
||||||
|
_settingsService.ShouldReuseMedia,
|
||||||
_settingsService.DateFormat
|
_settingsService.DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,13 @@
|
|||||||
Margin="8,0,0,0"
|
Margin="8,0,0,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="19"
|
FontSize="19"
|
||||||
|
FontWeight="Light"
|
||||||
TextTrimming="CharacterEllipsis"
|
TextTrimming="CharacterEllipsis"
|
||||||
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
|
<Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
|
||||||
<Run Text="/" />
|
<Run Text="/" />
|
||||||
<Run
|
<Run
|
||||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
FontWeight="SemiBold"
|
||||||
Text="{Binding Channels[0].Name, Mode=OneWay}"
|
Text="{Binding Channels[0].Name, Mode=OneWay}"
|
||||||
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
|
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
ToolTip="If this is set, only messages sent after this date will be exported" />
|
ToolTip="Only include messages sent after this date" />
|
||||||
<DatePicker
|
<DatePicker
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
@@ -103,7 +104,7 @@
|
|||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
||||||
ToolTip="If this is set, only messages sent before this date will be exported" />
|
ToolTip="Only include messages sent before this date" />
|
||||||
<materialDesign:TimePicker
|
<materialDesign:TimePicker
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
IsEnabled="{Binding IsAfterDateSet}"
|
IsEnabled="{Binding IsAfterDateSet}"
|
||||||
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
ToolTip="If this is set, only messages sent after this time will be exported" />
|
ToolTip="Only include messages sent after this time" />
|
||||||
<materialDesign:TimePicker
|
<materialDesign:TimePicker
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
@@ -121,7 +122,7 @@
|
|||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
IsEnabled="{Binding IsBeforeDateSet}"
|
IsEnabled="{Binding IsBeforeDateSet}"
|
||||||
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
ToolTip="If this is set, only messages sent before this time will be exported" />
|
ToolTip="Only include messages sent before this time" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Partitioning -->
|
<!-- Partitioning -->
|
||||||
@@ -130,10 +131,10 @@
|
|||||||
materialDesign:HintAssist.Hint="Messages per partition"
|
materialDesign:HintAssist.Hint="Messages per partition"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
Text="{Binding PartitionLimit, TargetNullValue=''}"
|
Text="{Binding PartitionLimit, TargetNullValue=''}"
|
||||||
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
|
ToolTip="Split output into partitions limited to this number of messages" />
|
||||||
|
|
||||||
<!-- Download media -->
|
<!-- Download media -->
|
||||||
<Grid Margin="16,16" ToolTip="If this is set, the export will include additional files such as user avatars, attached files, embedded images, etc">
|
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
@@ -142,7 +143,7 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Text="Download referenced media content" />
|
Text="Download media" />
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
@@ -163,18 +164,13 @@
|
|||||||
<ToggleButton
|
<ToggleButton
|
||||||
x:Name="AdvancedSectionToggleButton"
|
x:Name="AdvancedSectionToggleButton"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="8"
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="12"
|
||||||
Cursor="Hand"
|
Cursor="Hand"
|
||||||
Loaded="AdvancedSectionToggleButton_OnLoaded"
|
IsChecked="{Binding IsAdvancedSectionDisplayedByDefault, Mode=OneTime}"
|
||||||
Style="{DynamicResource MaterialDesignFlatToggleButton}"
|
Style="{DynamicResource MaterialDesignHamburgerToggleButton}"
|
||||||
ToolTip="Show advanced options">
|
ToolTip="Toggle advanced options" />
|
||||||
<ToggleButton.Content>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="24"
|
|
||||||
Height="24"
|
|
||||||
Kind="Menu" />
|
|
||||||
</ToggleButton.Content>
|
|
||||||
</ToggleButton>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.Windows;
|
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
|
||||||
{
|
{
|
||||||
public partial class ExportSetupView
|
public partial class ExportSetupView
|
||||||
{
|
{
|
||||||
@@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views.Dialogs
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AdvancedSectionToggleButton_OnLoaded(object sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is ExportSetupViewModel vm)
|
|
||||||
AdvancedSectionToggleButton.IsChecked = vm.IsAdvancedSectionDisplayedByDefault;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:s="https://github.com/canton7/Stylet"
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
Width="300"
|
Width="310"
|
||||||
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
|
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
|
||||||
Style="{DynamicResource MaterialDesignRoot}"
|
Style="{DynamicResource MaterialDesignRoot}"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
@@ -15,17 +15,8 @@
|
|||||||
<TextBlock
|
<TextBlock
|
||||||
Margin="16"
|
Margin="16"
|
||||||
FontSize="17"
|
FontSize="17"
|
||||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
|
||||||
Text="Settings" />
|
Text="Settings" />
|
||||||
|
|
||||||
<!-- Date format -->
|
|
||||||
<TextBox
|
|
||||||
Margin="16,8"
|
|
||||||
materialDesign:HintAssist.Hint="Date format"
|
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
|
||||||
Text="{Binding DateFormat}"
|
|
||||||
ToolTip="Format used when rendering dates (refer to .NET date format)" />
|
|
||||||
|
|
||||||
<!-- Auto-updates -->
|
<!-- Auto-updates -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
@@ -41,6 +32,24 @@
|
|||||||
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Dark mode -->
|
||||||
|
<DockPanel
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Use darker colors in the UI">
|
||||||
|
<TextBlock
|
||||||
|
Margin="16,8"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Dark mode" />
|
||||||
|
<ToggleButton
|
||||||
|
x:Name="DarkModeToggleButton"
|
||||||
|
Margin="16,8"
|
||||||
|
Checked="DarkModeToggleButton_Checked"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding IsDarkModeEnabled}"
|
||||||
|
Unchecked="DarkModeToggleButton_Unchecked" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Persist token -->
|
<!-- Persist token -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
@@ -56,14 +65,37 @@
|
|||||||
IsChecked="{Binding IsTokenPersisted}" />
|
IsChecked="{Binding IsTokenPersisted}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Reuse media -->
|
||||||
|
<DockPanel
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Reuse already existing media content to skip redundant downloads">
|
||||||
|
<TextBlock
|
||||||
|
Margin="16,8"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Reuse downloaded media" />
|
||||||
|
<ToggleButton
|
||||||
|
Margin="16,8"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding ShouldReuseMedia}" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Date format -->
|
||||||
|
<TextBox
|
||||||
|
Margin="16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Date format"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
Text="{Binding DateFormat}"
|
||||||
|
ToolTip="Format used when writing dates (uses .NET date formatting rules)" />
|
||||||
|
|
||||||
<!-- Parallel limit -->
|
<!-- Parallel limit -->
|
||||||
<StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time">
|
<StackPanel Background="Transparent" ToolTip="How many channels can be exported at the same time">
|
||||||
<TextBlock Margin="16,8">
|
<TextBlock Margin="16,8,16,4">
|
||||||
<Run Text="Parallel limit:" />
|
<Run Text="Parallel limit:" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding ParallelLimit, Mode=OneWay}" />
|
<Run Text="{Binding ParallelLimit, Mode=OneWay}" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<Slider
|
<Slider
|
||||||
Margin="16,8"
|
Margin="16,4,16,8"
|
||||||
IsSnapToTickEnabled="True"
|
IsSnapToTickEnabled="True"
|
||||||
Maximum="10"
|
Maximum="10"
|
||||||
Minimum="1"
|
Minimum="1"
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
namespace DiscordChatExporter.Gui.Views.Dialogs
|
using System.Windows;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Views.Dialogs
|
||||||
{
|
{
|
||||||
public partial class SettingsView
|
public partial class SettingsView
|
||||||
{
|
{
|
||||||
@@ -6,5 +8,11 @@
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) =>
|
||||||
|
App.SetDarkTheme();
|
||||||
|
|
||||||
|
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) =>
|
||||||
|
App.SetLightTheme();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,10 +46,7 @@
|
|||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<Grid
|
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
|
||||||
Grid.Row="0"
|
|
||||||
Background="{DynamicResource PrimaryHueMidBrush}"
|
|
||||||
TextElement.Foreground="{DynamicResource SecondaryInverseTextBrush}">
|
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
@@ -124,8 +121,12 @@
|
|||||||
Margin="6"
|
Margin="6"
|
||||||
Padding="4"
|
Padding="4"
|
||||||
Command="{s:Action ShowSettings}"
|
Command="{s:Action ShowSettings}"
|
||||||
Style="{DynamicResource MaterialDesignFlatDarkButton}"
|
Foreground="{DynamicResource MaterialDesignDarkForeground}"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||||
ToolTip="Settings">
|
ToolTip="Settings">
|
||||||
|
<Button.Resources>
|
||||||
|
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
|
||||||
|
</Button.Resources>
|
||||||
<materialDesign:PackIcon
|
<materialDesign:PackIcon
|
||||||
Width="24"
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
@@ -136,12 +137,20 @@
|
|||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Background="{DynamicResource PrimaryHueMidBrush}"
|
Background="{DynamicResource MaterialDesignDarkBackground}"
|
||||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
|
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<Grid Grid.Row="2" IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
<Grid
|
||||||
|
Grid.Row="2"
|
||||||
|
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||||
|
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
||||||
|
<Grid.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
</Style>
|
||||||
|
</Grid.Resources>
|
||||||
<!-- Placeholder / usage instructions -->
|
<!-- Placeholder / usage instructions -->
|
||||||
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
||||||
<!-- For user token -->
|
<!-- For user token -->
|
||||||
@@ -151,25 +160,25 @@
|
|||||||
<Run Text="1. Open Discord" />
|
<Run Text="1. Open Discord" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="2. Press" />
|
<Run Text="2. Press" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+Shift+I" />
|
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
|
||||||
<Run Text="to show developer tools" />
|
<Run Text="to show developer tools" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="3. Navigate to the" />
|
<Run Text="3. Navigate to the" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Application" />
|
<Run FontWeight="SemiBold" Text="Application" />
|
||||||
<Run Text="tab" />
|
<Run Text="tab" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="4. Select" />
|
<Run Text="4. Select" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Local Storage" />
|
<Run FontWeight="SemiBold" Text="Local Storage" />
|
||||||
<Run Text=">" />
|
<Run Text=">" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="https://discord.com" />
|
<Run FontWeight="SemiBold" Text="https://discord.com" />
|
||||||
<Run Text="on the left" />
|
<Run Text="on the left" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="5. Press" />
|
<Run Text="5. Press" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Ctrl+R" />
|
<Run FontWeight="SemiBold" Text="Ctrl+R" />
|
||||||
<Run Text="to reload" />
|
<Run Text="to reload" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="6. Find" />
|
<Run Text="6. Find" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="token" />
|
<Run FontWeight="SemiBold" Text="token" />
|
||||||
<Run Text="at the bottom and copy the value" />
|
<Run Text="at the bottom and copy the value" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
<TextBlock Margin="0,24,0,0" FontSize="14">
|
||||||
@@ -177,41 +186,55 @@
|
|||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="To authorize using bot token instead, click" />
|
<Run Text="To authorize using bot token instead, click" />
|
||||||
<InlineUIContainer>
|
<InlineUIContainer>
|
||||||
<materialDesign:PackIcon Margin="1,0,0,-3" Kind="Account" />
|
<materialDesign:PackIcon
|
||||||
|
Margin="1,0,0,-3"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Account" />
|
||||||
</InlineUIContainer>
|
</InlineUIContainer>
|
||||||
|
<Run Text="in the text box above." />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
<TextBlock Margin="0,24,0,0" FontSize="14">
|
||||||
<Run Text="For more information, check out the" />
|
<Run Text="For more information, check out the" />
|
||||||
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." />
|
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- For bot token -->
|
<!-- For bot token -->
|
||||||
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
<TextBlock FontSize="18" Text="Please provide your bot token to authorize" />
|
<TextBlock
|
||||||
<TextBlock Margin="8,8,0,0" FontSize="14">
|
FontSize="18"
|
||||||
|
FontWeight="Light"
|
||||||
|
Text="Please provide your bot token to authorize" />
|
||||||
|
<TextBlock
|
||||||
|
Margin="8,8,0,0"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="Light">
|
||||||
<Run Text="1. Open Discord developer portal" />
|
<Run Text="1. Open Discord developer portal" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="2. Open your application's settings" />
|
<Run Text="2. Open your application's settings" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="3. Navigate to the" />
|
<Run Text="3. Navigate to the" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Bot" />
|
<Run FontWeight="SemiBold" Text="Bot" />
|
||||||
<Run Text="section on the left" />
|
<Run Text="section on the left" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
<Run Text="4. Under" />
|
<Run Text="4. Under" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Token" />
|
<Run FontWeight="SemiBold" Text="Token" />
|
||||||
<Run Text="click" />
|
<Run Text="click" />
|
||||||
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="Copy" />
|
<Run FontWeight="SemiBold" Text="Copy" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
<TextBlock Margin="0,24,0,0" FontSize="14">
|
||||||
<Run Text="To authorize using user token instead, click" />
|
<Run Text="To authorize using user token instead, click" />
|
||||||
<InlineUIContainer>
|
<InlineUIContainer>
|
||||||
<materialDesign:PackIcon Margin="1,0,0,-1" Kind="Robot" />
|
<materialDesign:PackIcon
|
||||||
|
Margin="1,0,0,-1"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Robot" />
|
||||||
</InlineUIContainer>
|
</InlineUIContainer>
|
||||||
|
<Run Text="in the text box above." />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
<TextBlock Margin="0,24,0,0" FontSize="14">
|
||||||
<Run Text="For more information, check out the" />
|
<Run Text="For more information, check out the" />
|
||||||
<Hyperlink NavigateUri="https://github.com/Tyrrrz/DiscordChatExporter/wiki" RequestNavigate="Hyperlink_OnRequestNavigate">wiki</Hyperlink><Run Text="." />
|
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -226,7 +249,7 @@
|
|||||||
<!-- Guilds -->
|
<!-- Guilds -->
|
||||||
<Border
|
<Border
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
BorderBrush="{DynamicResource DividerBrush}"
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
BorderThickness="0,0,1,0">
|
BorderThickness="0,0,1,0">
|
||||||
<ListBox
|
<ListBox
|
||||||
ItemsSource="{Binding AvailableGuilds}"
|
ItemsSource="{Binding AvailableGuilds}"
|
||||||
@@ -245,13 +268,15 @@
|
|||||||
Width="48"
|
Width="48"
|
||||||
Height="48"
|
Height="48"
|
||||||
Margin="12,4,12,4"
|
Margin="12,4,12,4"
|
||||||
Fill="{DynamicResource DividerBrush}" />
|
Fill="{DynamicResource MaterialDesignDivider}" />
|
||||||
|
|
||||||
<!-- Guild icon -->
|
<!-- Guild icon -->
|
||||||
<Ellipse
|
<Ellipse
|
||||||
Width="48"
|
Width="48"
|
||||||
Height="48"
|
Height="48"
|
||||||
Margin="12,4,12,4">
|
Margin="12,4,12,4"
|
||||||
|
Stroke="{DynamicResource MaterialDesignDivider}"
|
||||||
|
StrokeThickness="1">
|
||||||
<Ellipse.Fill>
|
<Ellipse.Fill>
|
||||||
<ImageBrush ImageSource="{Binding IconUrl}" />
|
<ImageBrush ImageSource="{Binding IconUrl}" />
|
||||||
</Ellipse.Fill>
|
</Ellipse.Fill>
|
||||||
@@ -284,8 +309,8 @@
|
|||||||
Margin="0"
|
Margin="0"
|
||||||
Padding="0"
|
Padding="0"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
BorderBrush="{DynamicResource DividerBrush}"
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
BorderThickness="0,1,0,0"
|
BorderThickness="0,0,0,1"
|
||||||
Header="{Binding Name}"
|
Header="{Binding Name}"
|
||||||
IsExpanded="False">
|
IsExpanded="False">
|
||||||
<ItemsPresenter />
|
<ItemsPresenter />
|
||||||
@@ -322,7 +347,6 @@
|
|||||||
Margin="3,8,8,8"
|
Margin="3,8,8,8"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontSize="14"
|
FontSize="14"
|
||||||
Foreground="{DynamicResource PrimaryTextBrush}"
|
|
||||||
Text="{Binding Name, Mode=OneWay}" />
|
Text="{Binding Name, Mode=OneWay}" />
|
||||||
|
|
||||||
<!-- Is selected checkmark -->
|
<!-- Is selected checkmark -->
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
using System.Windows.Navigation;
|
namespace DiscordChatExporter.Gui.Views
|
||||||
using DiscordChatExporter.Gui.Internal;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Views
|
|
||||||
{
|
{
|
||||||
public partial class RootView
|
public partial class RootView
|
||||||
{
|
{
|
||||||
@@ -9,11 +6,5 @@ namespace DiscordChatExporter.Gui.Views
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Hyperlink_OnRequestNavigate(object sender, RequestNavigateEventArgs e)
|
|
||||||
{
|
|
||||||
ProcessEx.StartShellExecute(e.Uri.AbsoluteUri);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||||
<Version>2.22</Version>
|
<Version>2.25</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# Build
|
# Build
|
||||||
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:3.1 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY favicon.ico ./
|
COPY favicon.ico ./
|
||||||
@@ -10,7 +10,7 @@ COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
|||||||
RUN dotnet publish DiscordChatExporter.Cli -o DiscordChatExporter.Cli/publish -c Release
|
RUN dotnet publish DiscordChatExporter.Cli -o DiscordChatExporter.Cli/publish -c Release
|
||||||
|
|
||||||
# Run
|
# Run
|
||||||
FROM mcr.microsoft.com/dotnet/core/runtime:3.1 AS run
|
FROM mcr.microsoft.com/dotnet/runtime:3.1 AS run
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=build /src/DiscordChatExporter.Cli/publish ./
|
COPY --from=build /src/DiscordChatExporter.Cli/publish ./
|
||||||
|
|||||||
@@ -6,21 +6,24 @@
|
|||||||
[](https://tyrrrz.me/donate)
|
[](https://tyrrrz.me/donate)
|
||||||
[](https://xscode.com/Tyrrrz/DiscordChatExporter)
|
[](https://xscode.com/Tyrrrz/DiscordChatExporter)
|
||||||
|
|
||||||
|
**Project status: active**.
|
||||||
|
|
||||||
DiscordChatExporter can be used to export message history from a [Discord](https://discord.com) channel to a file. It works with direct messages, group messages, server channels, supports Discord's dialect of markdown and all other rich media features.
|
DiscordChatExporter can be used to export message history from a [Discord](https://discord.com) channel to a file. It works with direct messages, group messages, server channels, supports Discord's dialect of markdown and all other rich media features.
|
||||||
|
|
||||||
_For guides and other info -- check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)._
|
Have questions or need help? Check out the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki).
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
- **[Latest release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**
|
- **[Latest release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**
|
||||||
- [Docker image](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
|
- [Docker image](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
|
||||||
|
- [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli/): `yay -S discord-chat-exporter-cli`
|
||||||
- [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
- [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
||||||
|
|
||||||
Note: This application requires .NET Core runtime in order to run:
|
Note: This application requires .NET Core v3.1 runtime in order to run:
|
||||||
|
|
||||||
- .NET Core runtime for Windows: [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x64-installer) | [x86](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x86-installer)
|
- .NET Runtime for Windows: [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x64-installer) | [x86](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x86-installer)
|
||||||
- .NET Core runtime for macOS (for CLI version only): [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-3.1.0-macos-x64-installer)
|
- .NET Runtime for macOS (for CLI version only): [x64](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-3.1.0-macos-x64-installer)
|
||||||
- .NET Core runtime for Linux (for CLI version only): [Ubuntu/Debian](https://docs.microsoft.com/en-us/dotnet/core/install/linux-package-manager-ubuntu-1904#install-the-net-core-runtime)
|
- .NET Runtime for Linux (for CLI version only): [Ubuntu/Debian](https://docs.microsoft.com/en-us/dotnet/core/install/linux-package-manager-ubuntu-1904#install-the-net-core-runtime)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -37,4 +40,4 @@ Note: This application requires .NET Core runtime in order to run:
|
|||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|

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