mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9639b6c550 | |||
| 950658c737 | |||
| 560a069c35 | |||
| 1131f8659d | |||
| 38be44debb | |||
| 4b243f2252 | |||
| a31175b2e8 | |||
| 913255f04f | |||
| 873d055191 | |||
| dabed24c16 | |||
| fdf421df3c | |||
| 2259f19c44 | |||
| 8695942328 | |||
| 84e656ccfc | |||
| 2ba0c3b38e | |||
| 7467f0aeb6 | |||
| e38479d463 | |||
| 06a4b6a8e6 | |||
| cc1ad8b435 | |||
| f8dac2c9d0 | |||
| b8cea0d140 | |||
| 7996a38c5c | |||
| 9d052f9404 | |||
| 3078351670 | |||
| 4f41c4fff3 | |||
| a80ee2943f | |||
| 8612d2c84a | |||
| 57c849d0f8 | |||
| 9c2a26aa82 | |||
| f376028d76 | |||
| da87f3c774 | |||
| 61516feafa | |||
| 8d209cd67a | |||
| c58d7e752b | |||
| fcc1605455 | |||
| f28d662a71 | |||
| a405ab184e | |||
| b861a69799 | |||
| d53b4c825a | |||
| 30678e96e5 | |||
| 2024d3658b | |||
| 9453cac389 | |||
| 504c3c53cd | |||
| 4e358e8e6f | |||
| 5e852235ce | |||
| 612c6d37a4 | |||
| 92cf886eab | |||
| f48af53d72 | |||
| 99d721c63b | |||
| 11414b5937 | |||
| 6c039ebfa6 | |||
| 9cffbbc5ce | |||
| 6f87960513 | |||
| e8f8c47dbd | |||
| 9aba36fce5 | |||
| f29e1d2d9f | |||
| b000a72f81 |
@@ -1,5 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠ Feature request
|
||||
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
|
||||
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
|
||||
- name: 💬 Discord server
|
||||
url: https://discord.gg/2SUWKFnHSm
|
||||
about: Chat with the project community.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
name: ✨ Feature request
|
||||
description: Request a new feature.
|
||||
labels: [enhancement]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
🧐 **Guidelines:**
|
||||
|
||||
- Search through [existing issues](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) first to ensure that this feature has not been requested before.
|
||||
- Write a descriptive title for your issue. Avoid generic or vague titles such as "Some suggestions" or "Ideas for improvement".
|
||||
- Keep your issue focused on one single problem. If you have multiple feature requests, please create separate issues for each of them.
|
||||
- Provide as much context as possible in the details section. Include screenshots, screen recordings, links, references, or anything else you may consider relevant.
|
||||
- If you want to ask a question instead of requesting a feature, please use [discussions](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Flavor
|
||||
description: Is this feature request related to specific flavor(s) of DiscordChatExporter? If not applicable, leave this field blank.
|
||||
multiple: true
|
||||
options:
|
||||
- GUI (Graphical User Interface)
|
||||
- CLI (Command Line Interface)
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Export format
|
||||
description: Is this feature request related to specific export format(s)? If not applicable, leave this field blank.
|
||||
multiple: true
|
||||
options:
|
||||
- HTML
|
||||
- TXT
|
||||
- JSON
|
||||
- CSV
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: Clear and thorough explanation of the feature you have in mind.
|
||||
validations:
|
||||
required: true
|
||||
@@ -18,8 +18,7 @@ jobs:
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||
dotnet-version: 6.0.100
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Run tests
|
||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||
@@ -34,7 +33,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
publish:
|
||||
package:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
|
||||
@@ -50,8 +49,7 @@ jobs:
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||
dotnet-version: 6.0.100
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Publish (CLI)
|
||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||
@@ -73,7 +71,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
|
||||
needs: publish
|
||||
needs: package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*.sln.docstates
|
||||
.idea/
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
|
||||
+47
-3
@@ -1,3 +1,46 @@
|
||||
### v2.36.4 (29-Oct-2022)
|
||||
|
||||
- Changed all mentions of "media" in the context of "download media" or "reuse media" to "assets". CLI options will retain their existing names for backwards compatibility.
|
||||
- [HTML] Fixed an issue which prevented emoji used inside message content from being downloaded when "download assets" option is enabled. (Thanks [@Roberto Blázquez](https://github.com/xBaank))
|
||||
|
||||
### v2.36.3 (21-Oct-2022)
|
||||
|
||||
- [GUI] Fixed an issue where opening a dialog did not prevent user interactions with background UI elements using keyboard. This sometimes caused the application to crash in weird ways.
|
||||
- [Docker] Fixed an issue where DiscordChatExporter was running as root inside the container, making the exported files inaccessible to the host. (Thanks [@Benjamin Just](https://github.com/BamButz))
|
||||
|
||||
### v2.36.2 (08-Oct-2022)
|
||||
|
||||
- Removed the message content intent check because the heuristics turned out to be not reliable enough.
|
||||
- Fixed an issue where the runtime manifest included the wrong target runtime version. This resulted in the application failing to launch for some users when upgrading from earlier versions.
|
||||
|
||||
### v2.36.1 (24-Sep-2022)
|
||||
|
||||
- Added a check which will trigger an error if the provided bot account does not have the message content intent enabled. Note, however, that this check is based on heuristics which may result in false negatives.
|
||||
- Fixed an issue where certain transient HTTP errors were not retried.
|
||||
- Fixed an issue which caused the export process to fail with the `IndexOutOfRangeException` exception on certain automated messages.
|
||||
- Fixed an issue which caused the export process to fail on unrecognized embed types.
|
||||
|
||||
### v2.36 (16-Sep-2022)
|
||||
|
||||
- [HTML] Added support for rendering GIFV embeds. They will now render as videos that automatically play when you hover your mouse over them. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
|
||||
- [HTML] Added support for rendering system notification messages, such as when a user joins a server or when a messages gets pinned. Previously, such messages were rendered as regular text messages, but now they look similar to how they're presented in the Discord client. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
|
||||
- [CLI] Improved the wording of the error message that's shown when exporting multiple channels and the provided output path is ambiguous. It now mentions that you can resolve the ambiguity by adding a slash at the end of the path.
|
||||
|
||||
### v2.35.2 (08-Sep-2022)
|
||||
|
||||
- Updated usage guide to mention the need to enable "Message Content Intent" to use a bot as a medium for exporting messages.
|
||||
- [CLI] Fixed an issue where passing an existing directory as the output path failed when exporting multiple channels (for example, when running `exportdm`). An ambiguous output path will now always be treated as a directory path if that directory already exists. If it doesn't exist, it will be treated as a file path, matching the previous behavior.
|
||||
|
||||
### v2.35.1 (26-Aug-2022)
|
||||
|
||||
- Added `~` character as an alias for `-` when negating a message filter. This should make it easier to use negated filters from CLI where the dash character already has other meanings.
|
||||
- Extended retry logic to encompass all server-side errors from Discord API, instead of only `500 Internal Server Error`.
|
||||
- [CLI] Attempt to export multiple channels will now fail if provided with an output path that is neither a directory, nor a file name template. Previously, this resulted in all channels being exported to the same file, overwriting each other.
|
||||
- Fixed an issue where a text message filter didn't work if it started with a character that's also the first character in one of the named filters (e.g. `from` or `mentions`).
|
||||
- Fixed an issue where a text message filter didn't work correctly if it was enclosed in parentheses.
|
||||
- Fixed an issue where categories were displayed alongside regular channels in the channel list, both in GUI and in CLI (when using the `guild` command).
|
||||
- [CLI] Fixed an issue where an output path starting with a tilde on Linux was not expanded properly into a full path. (Thanks [@TSRBerry](https://github.com/TSRBerry))
|
||||
|
||||
### v2.35 (02-Jul-2022)
|
||||
|
||||
- Added support for exporting text chat in voice channels. These channels are now also displayed when browsing in the GUI and in the CLI when executing the `channels` command.
|
||||
@@ -17,6 +60,7 @@
|
||||
- Added file name template token that resolves to the current date (`%d`). (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- Updated the usage guide to replace the instructions for retrieving the user token with those that appear to work for more people.
|
||||
- Updated the usage guide to feature the TOS warning more prominently.
|
||||
- [Docker] Changed internal working directory from `/app/out` to `/out`. This was an unintended breaking change. Please update your volume binding configuration from `-v /path/on/machine:/app/out` to `-v /path/on/machine:/out`.
|
||||
|
||||
### v2.34.1 (01-Jun-2022)
|
||||
|
||||
@@ -86,7 +130,7 @@
|
||||
|
||||
- [HTML] Added special casing for Spotify track embeds to render them directly using an embedded player.
|
||||
- [HTML] Added special casing for plain image embeds to render them like image attachments.
|
||||
- [HTML] Added tooltip text for unix timestamp markers which shows the full date represented by the timestamp, regardless of configured date format.
|
||||
- [HTML] Added tooltip text for unix timestamp markers which shows the full date represented by the timestamp, regardless of configured date format.
|
||||
- [HTML] Updated the fallback text shown when the referenced message could not be loaded to make it more explicit.
|
||||
- [HTML] Updated styling of quoted text in markdown to more closely resemble how it looks in Discord.
|
||||
- [HTML] Fixed an issue where some file formats were not correctly recognized as image or video attachments, which affected how they were rendered.
|
||||
@@ -113,7 +157,7 @@
|
||||
- [HTML] Added special casing for YouTube video embeds to render them directly using an embedded player. (Thanks [@quentinmay](https://github.com/quentinmay))
|
||||
- [HTML] Added support for rendering standard emoji by code. Such emoji may sometimes be found in messages sent by bots or through webhooks. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- [HTML] Changed tooltips on standard emoji to show emoji code instead of their raw string representations. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- [HTML] Added tooltips on individual messages to show when those messages were sent.
|
||||
- [HTML] Added tooltips on individual messages to show when those messages were sent.
|
||||
- [HTML] Updated colors and minor styling elements to match Discord's new style direction after rebranding.
|
||||
- [HTML] Updated Twemoji URLs to use SVG image variants for better rendering quality.
|
||||
- [HTML] Changed user avatar URLs to include predetermined size query parameter for better rendering quality.
|
||||
@@ -209,7 +253,7 @@
|
||||
- Added a new option that enables self-contained exports for all output formats. You can turn it on in the export setup dialog in GUI or using the `--media` option in CLI. When using this, the application will additionally download any media content directly referenced from the exported file instead of linking back to Discord CDN. The files which are downloaded include: guild icons, user avatars, attachments, embedded images, reaction emoji. Note that only files which are actually referenced by the export are downloaded, which means that, for example, user avatars will not be downloaded when using plain text export format. This option is not meant to enable complete offline viewing for HTML exports, but rather to make it easier to archive media content that may eventually get deleted from Discord servers. Also keep in mind that this option may make the export drastically slower and the total file size larger.
|
||||
- Changed "discordapp.com" to "discord.com" where applicable as Discord is migrating to a new domain. CDN will remain on "cdn.discordapp.com" for the foreseeable future.
|
||||
|
||||
Note that all existing and current HTML exports will likely not render accurately because Discord enabled CORS for their font resources, which prevents them from loading locally. Please refer to [issue #322](https://github.com/Tyrrrz/DiscordChatExporter/issues/322) for discussion on this topic.
|
||||
Note that all existing and current HTML exports will likely not render accurately because Discord enabled CORS for their font resources, which prevents them from loading locally. Please refer to [issue #322](https://github.com/Tyrrrz/DiscordChatExporter/issues/322) for discussion on this topic.
|
||||
|
||||
### v2.20 (27-Apr-2020)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Version>2.35</Version>
|
||||
<Version>2.36.4</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
@@ -10,4 +10,12 @@
|
||||
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
Even though the project builds against .NET 6, some dependencies
|
||||
apparently rely on a specific version of the runtime.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<RuntimeFrameworkVersion>6.0.9</RuntimeFrameworkVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -12,12 +12,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="0.17.1" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.7.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
|
||||
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit" Version="2.4.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -54,8 +54,9 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
|
||||
message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.Where(s => s?.EndsWith("i.redd.it/f8w05ja8s4e61.png") ?? false)
|
||||
.Should()
|
||||
.Contain("https://i.redd.it/f8w05ja8s4e61.png");
|
||||
.ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -74,6 +75,38 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
|
||||
content.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_containing_a_gifv_link_is_rendered_with_a_video_embed()
|
||||
{
|
||||
// Act
|
||||
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.EmbedTestCases,
|
||||
Snowflake.Parse("1019234520349814794")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
.QuerySelectorAll("source")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.Where(s => s?.EndsWith("media.tenor.com/DDAJeW6BQKkAAAPo/tooncasm-test-copy.mp4") ?? false)
|
||||
.Should()
|
||||
.ContainSingle();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_containing_a_gifv_link_and_nothing_else_is_rendered_without_text_content()
|
||||
{
|
||||
// Act
|
||||
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.EmbedTestCases,
|
||||
Snowflake.Parse("1019234520349814794")
|
||||
);
|
||||
|
||||
// Assert
|
||||
var content = message.QuerySelector(".chatlog__content")?.Text();
|
||||
content.Should().BeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed()
|
||||
{
|
||||
|
||||
@@ -36,7 +36,7 @@ public class SelfContainedSpecs : IClassFixture<TempOutputFixture>
|
||||
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
ShouldDownloadMedia = true
|
||||
ShouldDownloadAssets = true
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -7,12 +7,16 @@ COPY Directory.Build.props ./
|
||||
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core
|
||||
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli
|
||||
|
||||
RUN dotnet publish DiscordChatExporter.Cli -c Release -o ./publish
|
||||
RUN dotnet publish DiscordChatExporter.Cli --configuration Release --output ./publish
|
||||
|
||||
# Run
|
||||
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run
|
||||
|
||||
RUN useradd dce
|
||||
USER dce
|
||||
|
||||
COPY --from=build ./publish ./
|
||||
|
||||
WORKDIR ./out
|
||||
ENTRYPOINT ["dotnet", "/DiscordChatExporter.Cli.dll"]
|
||||
|
||||
ENTRYPOINT ["dotnet", "../DiscordChatExporter.Cli.dll"]
|
||||
|
||||
@@ -14,18 +14,27 @@ using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using Gress;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
public abstract class ExportCommandBase : TokenCommandBase
|
||||
{
|
||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption(
|
||||
"output",
|
||||
'o',
|
||||
Description = "Output file or directory path."
|
||||
Description = "Output file or directory path. Directory path should end in a slash."
|
||||
)]
|
||||
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
|
||||
public string OutputPath
|
||||
{
|
||||
get => _outputPath;
|
||||
// Handle ~/ in paths on *nix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => _outputPath = Path.GetFullPath(value);
|
||||
}
|
||||
|
||||
[CommandOption(
|
||||
"format",
|
||||
@@ -67,15 +76,15 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
|
||||
[CommandOption(
|
||||
"media",
|
||||
Description = "Download referenced media content."
|
||||
Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
|
||||
)]
|
||||
public bool ShouldDownloadMedia { get; init; }
|
||||
public bool ShouldDownloadAssets { get; init; }
|
||||
|
||||
[CommandOption(
|
||||
"reuse-media",
|
||||
Description = "Reuse already existing media content to skip redundant downloads."
|
||||
Description = "Reuse previously downloaded assets to avoid redundant requests."
|
||||
)]
|
||||
public bool ShouldReuseMedia { get; init; }
|
||||
public bool ShouldReuseAssets { get; init; }
|
||||
|
||||
[CommandOption(
|
||||
"dateformat",
|
||||
@@ -88,13 +97,36 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
|
||||
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
if (ShouldReuseMedia && !ShouldDownloadMedia)
|
||||
// Reuse assets option should only be used when the download assets option is set.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||
{
|
||||
throw new CommandException("Option --reuse-media cannot be used without --media.");
|
||||
throw new CommandException(
|
||||
"Option --reuse-media cannot be used without --media."
|
||||
);
|
||||
}
|
||||
|
||||
// Make sure the user does not try to export all channels into a single file.
|
||||
// Output path must either be a directory, or contain template tokens.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||
var isValidOutputPath =
|
||||
// Anything is valid when exporting a single channel
|
||||
channels.Count <= 1 ||
|
||||
// When using template tokens, assume the user knows what they're doing
|
||||
OutputPath.Contains('%') ||
|
||||
// Otherwise, require an existing directory or an unambiguous directory path
|
||||
Directory.Exists(OutputPath) || PathEx.IsDirectoryPath(OutputPath);
|
||||
|
||||
if (!isValidOutputPath)
|
||||
{
|
||||
throw new CommandException(
|
||||
"Attempted to export multiple channels, but the output path is neither a directory nor a template. " +
|
||||
"If the provided output path is meant to be treated as a directory, make sure it ends with a slash."
|
||||
);
|
||||
}
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var errors = new ConcurrentDictionary<Channel, string>();
|
||||
|
||||
// Export
|
||||
@@ -126,8 +158,8 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
Before,
|
||||
PartitionLimit,
|
||||
MessageFilter,
|
||||
ShouldDownloadMedia,
|
||||
ShouldReuseMedia,
|
||||
ShouldDownloadAssets,
|
||||
ShouldReuseAssets,
|
||||
DateFormat
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
@@ -17,7 +18,10 @@ public class ExportDirectMessagesCommand : ExportCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.ToArray();
|
||||
|
||||
await base.ExecuteAsync(console, channels);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
@@ -25,7 +27,10 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.ToArray();
|
||||
|
||||
await base.ExecuteAsync(console, channels);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
@@ -25,6 +26,7 @@ public class GetChannelsCommand : TokenCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.OrderBy(c => c.Category.Position)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToArray();
|
||||
|
||||
@@ -17,6 +17,7 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.OrderByDescending(c => c.LastMessageId)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToArray();
|
||||
|
||||
@@ -17,13 +17,14 @@ public class GuideCommand : ICommand
|
||||
|
||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!");
|
||||
console.Output.WriteLine(" 1. Open Discord in your web browser and login");
|
||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||
console.Output.WriteLine(" 3. Navigate to the Network tab");
|
||||
console.Output.WriteLine(" 4. Press Ctrl+R to reload");
|
||||
console.Output.WriteLine(" 5. Search for a request containing \"messages?limit=50\" or similar");
|
||||
console.Output.WriteLine(" 6. Select the Headers tab on the right");
|
||||
console.Output.WriteLine(" 7. Scroll down to the Request Headers section");
|
||||
console.Output.WriteLine(" 8. Copy the value of the \"authorization\" header");
|
||||
console.Output.WriteLine(" 2. Open any server or direct message channel");
|
||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
console.Output.WriteLine(" 4. Navigate to the Network tab");
|
||||
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
|
||||
console.Output.WriteLine(" 6. Search for a request containing \"messages?limit=50\" or similar");
|
||||
console.Output.WriteLine(" 7. Select the Headers tab on the right");
|
||||
console.Output.WriteLine(" 8. Scroll down to the Request Headers section");
|
||||
console.Output.WriteLine(" 9. Copy the value of the \"authorization\" header");
|
||||
console.Output.WriteLine();
|
||||
|
||||
// Bot token
|
||||
@@ -34,6 +35,7 @@ public class GuideCommand : ICommand
|
||||
console.Output.WriteLine(" 2. Open your application's settings");
|
||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||
console.Output.WriteLine(" * Your bot needs to have Message Content Intent enabled to read messages");
|
||||
console.Output.WriteLine();
|
||||
|
||||
// Guild or channel ID
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.2.5" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.44.0" />
|
||||
<PackageReference Include="CliFx" Version="2.3.0" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.45.0" />
|
||||
<PackageReference Include="Gress" Version="2.0.1" />
|
||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object
|
||||
public partial record Embed(
|
||||
string? Title,
|
||||
EmbedKind Kind,
|
||||
string? Url,
|
||||
DateTimeOffset? Timestamp,
|
||||
Color? Color,
|
||||
@@ -19,11 +20,9 @@ public partial record Embed(
|
||||
IReadOnlyList<EmbedField> Fields,
|
||||
EmbedImage? Thumbnail,
|
||||
IReadOnlyList<EmbedImage> Images,
|
||||
EmbedVideo? Video,
|
||||
EmbedFooter? Footer)
|
||||
{
|
||||
public PlainImageEmbedProjection? TryGetPlainImage() =>
|
||||
PlainImageEmbedProjection.TryResolve(this);
|
||||
|
||||
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
|
||||
SpotifyTrackEmbedProjection.TryResolve(this);
|
||||
|
||||
@@ -36,9 +35,20 @@ public partial record Embed
|
||||
public static Embed Parse(JsonElement json)
|
||||
{
|
||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
|
||||
EmbedKind.Rich;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
||||
var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
||||
|
||||
var color = json
|
||||
.GetPropertyOrNull("color")?
|
||||
.GetInt32OrNull()?
|
||||
.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha();
|
||||
|
||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
||||
|
||||
@@ -59,10 +69,13 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ??
|
||||
Array.Empty<EmbedImage>();
|
||||
|
||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||
|
||||
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
||||
|
||||
return new Embed(
|
||||
title,
|
||||
kind,
|
||||
url,
|
||||
timestamp,
|
||||
color,
|
||||
@@ -71,6 +84,7 @@ public partial record Embed
|
||||
fields,
|
||||
thumbnail,
|
||||
images,
|
||||
video,
|
||||
footer
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-types
|
||||
public enum EmbedKind
|
||||
{
|
||||
Rich,
|
||||
Image,
|
||||
Video,
|
||||
Gifv,
|
||||
Link
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using JsonExtensions.Reading;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure
|
||||
public record EmbedVideo(
|
||||
string? Url,
|
||||
string? ProxyUrl,
|
||||
int? Width,
|
||||
int? Height)
|
||||
{
|
||||
public static EmbedVideo Parse(JsonElement json)
|
||||
{
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetNonWhiteSpaceStringOrNull();
|
||||
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
||||
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
||||
|
||||
return new EmbedVideo(url, proxyUrl, width, height);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
public record PlainImageEmbedProjection(string Url)
|
||||
{
|
||||
public static PlainImageEmbedProjection? TryResolve(Embed embed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||
return null;
|
||||
|
||||
// Has to be an embed without any data (except URL and image)
|
||||
if (!string.IsNullOrWhiteSpace(embed.Title) ||
|
||||
embed.Timestamp is not null ||
|
||||
embed.Author is not null ||
|
||||
!string.IsNullOrWhiteSpace(embed.Description) ||
|
||||
embed.Fields.Any() ||
|
||||
embed.Footer is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Has to be an image file
|
||||
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
|
||||
return null;
|
||||
|
||||
return new PlainImageEmbedProjection(embed.Url);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ public partial record SpotifyTrackEmbedProjection
|
||||
|
||||
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
|
||||
{
|
||||
if (embed.Kind != EmbedKind.Link)
|
||||
return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||
return null;
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ public partial record YouTubeVideoEmbedProjection
|
||||
|
||||
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
|
||||
{
|
||||
if (embed.Kind != EmbedKind.Video)
|
||||
return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||
return null;
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
public partial record Emoji(
|
||||
// Only present on custom emoji
|
||||
Snowflake? Id,
|
||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||
string Name,
|
||||
bool IsAnimated,
|
||||
string ImageUrl)
|
||||
{
|
||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
public string Code => Id is not null
|
||||
? Name
|
||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
@@ -24,17 +24,19 @@ public partial record Emoji(
|
||||
|
||||
public partial record Emoji
|
||||
{
|
||||
private static string GetTwemojiName(string name)
|
||||
private static string GetTwemojiId(string name)
|
||||
{
|
||||
var runes = name.GetRunes();
|
||||
var runes = name.GetRunes().ToArray();
|
||||
|
||||
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner.
|
||||
// VS = 0xfe0f; ZWJ = 0x200d.
|
||||
var filteredRunes = runes.Any(r => r.Value == 0x200d)
|
||||
? runes
|
||||
: runes.Where(r => r.Value != 0xfe0f);
|
||||
|
||||
return string.Join(
|
||||
"-",
|
||||
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner.
|
||||
// VS = 0xfe0f. ZWJ = 0x200d.
|
||||
runes.Any(r => r.Value == 0x200d)
|
||||
? runes.Select(r => r.Value.ToString("x"))
|
||||
: runes.Where(r => r.Value != 0xfe0f).Select(r => r.Value.ToString("x"))
|
||||
filteredRunes.Select(r => r.Value.ToString("x"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ public partial record Emoji
|
||||
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||
|
||||
private static string GetImageUrl(string name) =>
|
||||
$"https://twemoji.maxcdn.com/2/svg/{GetTwemojiName(name)}.svg";
|
||||
$"https://twemoji.maxcdn.com/v/latest/svg/{GetTwemojiId(name)}.svg";
|
||||
|
||||
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
@@ -17,8 +18,14 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
||||
private static string GetDefaultIconUrl() =>
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
|
||||
private static string GetIconUrl(Snowflake id, string iconHash) =>
|
||||
$"https://cdn.discordapp.com/icons/{id}/{iconHash}.png";
|
||||
private static string GetIconUrl(Snowflake id, string iconHash)
|
||||
{
|
||||
var extension = iconHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? "gif"
|
||||
: "png";
|
||||
|
||||
return $"https://cdn.discordapp.com/icons/{id}/{iconHash}.{extension}";
|
||||
}
|
||||
|
||||
public static Guild Parse(JsonElement json)
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ public record Message(
|
||||
{
|
||||
var embed = embeds[i];
|
||||
|
||||
if (embed.Url?.Contains("://twitter.com/") == true)
|
||||
if (embed.Url?.Contains("://twitter.com/", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
// Find embeds with the same URL that only contain a single image and nothing else
|
||||
var trailingEmbeds = embeds
|
||||
@@ -107,6 +107,7 @@ public record Message(
|
||||
MessageKind.ChannelNameChange => "Changed the channel name.",
|
||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.ThreadCreated => "Started a thread.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
|
||||
};
|
||||
|
||||
@@ -11,5 +11,11 @@ public enum MessageKind
|
||||
ChannelIconChange = 5,
|
||||
ChannelPinnedMessage = 6,
|
||||
GuildMemberJoin = 7,
|
||||
ThreadCreated = 18,
|
||||
Reply = 19
|
||||
}
|
||||
|
||||
public static class MessageKindExtensions
|
||||
{
|
||||
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
|
||||
}
|
||||
@@ -22,65 +22,67 @@ public class DiscordClient
|
||||
private readonly string _token;
|
||||
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
|
||||
|
||||
private TokenKind _tokenKind = TokenKind.Unknown;
|
||||
private TokenKind? _resolvedTokenKind;
|
||||
|
||||
public DiscordClient(string token) => _token = token;
|
||||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||
string url,
|
||||
bool isBot,
|
||||
TokenKind tokenKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
|
||||
// Don't validate because token can have invalid characters
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
isBot ? $"Bot {_token}" : _token
|
||||
);
|
||||
// Don't validate because token can have invalid characters
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
tokenKind == TokenKind.Bot
|
||||
? $"Bot {_token}"
|
||||
: _token
|
||||
);
|
||||
|
||||
return await Http.Client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
return await Http.Client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
innerCancellationToken
|
||||
);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask<TokenKind> GetTokenKindAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Try authenticating as a user
|
||||
using var userResponse = await GetResponseAsync(
|
||||
"users/@me",
|
||||
TokenKind.User,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||
return TokenKind.User;
|
||||
|
||||
// Try authenticating as a bot
|
||||
using var botResponse = await GetResponseAsync(
|
||||
"users/@me",
|
||||
TokenKind.Bot,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||
return TokenKind.Bot;
|
||||
|
||||
throw DiscordChatExporterException.Unauthorized();
|
||||
}
|
||||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||
{
|
||||
if (_tokenKind == TokenKind.User)
|
||||
return await GetResponseAsync(url, false, innerCancellationToken);
|
||||
|
||||
if (_tokenKind == TokenKind.Bot)
|
||||
return await GetResponseAsync(url, true, innerCancellationToken);
|
||||
|
||||
// Try to authenticate as user
|
||||
var userResponse = await GetResponseAsync(url, false, innerCancellationToken);
|
||||
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_tokenKind = TokenKind.User;
|
||||
return userResponse;
|
||||
}
|
||||
|
||||
userResponse.Dispose();
|
||||
|
||||
// Otherwise, try to authenticate as bot
|
||||
var botResponse = await GetResponseAsync(url, true, innerCancellationToken);
|
||||
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||
{
|
||||
_tokenKind = TokenKind.Bot;
|
||||
return botResponse;
|
||||
}
|
||||
|
||||
// The token is probably invalid altogether.
|
||||
// Return the last response anyway, upstream should handle the error.
|
||||
return botResponse;
|
||||
}, cancellationToken);
|
||||
var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
|
||||
return await GetResponseAsync(url, tokenKind, cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask<JsonElement> GetJsonResponseAsync(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
public enum TokenKind
|
||||
{
|
||||
Unknown,
|
||||
User,
|
||||
Bot
|
||||
}
|
||||
@@ -7,10 +7,10 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Gress" Version="2.0.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.1" />
|
||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.2" />
|
||||
<PackageReference Include="Polly" Version="7.2.3" />
|
||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.9.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.12.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+7
-7
@@ -12,18 +12,18 @@ using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
internal partial class MediaDownloader
|
||||
internal partial class ExportAssetDownloader
|
||||
{
|
||||
private readonly string _workingDirPath;
|
||||
private readonly bool _reuseMedia;
|
||||
private readonly bool _reuse;
|
||||
|
||||
// File paths of already downloaded media
|
||||
// File paths of the previously downloaded assets
|
||||
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
|
||||
|
||||
public MediaDownloader(string workingDirPath, bool reuseMedia)
|
||||
public ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
{
|
||||
_workingDirPath = workingDirPath;
|
||||
_reuseMedia = reuseMedia;
|
||||
_reuse = reuse;
|
||||
}
|
||||
|
||||
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default)
|
||||
@@ -35,7 +35,7 @@ internal partial class MediaDownloader
|
||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||
|
||||
// Reuse existing files if we're allowed to
|
||||
if (!_reuseMedia || !File.Exists(filePath))
|
||||
if (!_reuse || !File.Exists(filePath))
|
||||
{
|
||||
Directory.CreateDirectory(_workingDirPath);
|
||||
|
||||
@@ -76,7 +76,7 @@ internal partial class MediaDownloader
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class MediaDownloader
|
||||
internal partial class ExportAssetDownloader
|
||||
{
|
||||
private static string GetUrlHash(string url)
|
||||
{
|
||||
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
internal class ExportContext
|
||||
{
|
||||
private readonly MediaDownloader _mediaDownloader;
|
||||
private readonly ExportAssetDownloader _assetDownloader;
|
||||
|
||||
public ExportRequest Request { get; }
|
||||
|
||||
@@ -35,14 +35,14 @@ internal class ExportContext
|
||||
Channels = channels;
|
||||
Roles = roles;
|
||||
|
||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
|
||||
_assetDownloader = new ExportAssetDownloader(request.OutputAssetsDirPath, request.ShouldReuseAssets);
|
||||
}
|
||||
|
||||
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
|
||||
{
|
||||
"unix" => date.ToUnixTimeSeconds().ToString(),
|
||||
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
|
||||
var dateFormat => date.ToLocalString(dateFormat)
|
||||
var format => date.ToLocalString(format)
|
||||
};
|
||||
|
||||
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
|
||||
@@ -63,14 +63,14 @@ internal class ExportContext
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async ValueTask<string> ResolveMediaUrlAsync(string url, CancellationToken cancellationToken = default)
|
||||
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Request.ShouldDownloadMedia)
|
||||
if (!Request.ShouldDownloadAssets)
|
||||
return url;
|
||||
|
||||
try
|
||||
{
|
||||
var filePath = await _mediaDownloader.DownloadAsync(url, cancellationToken);
|
||||
var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken);
|
||||
|
||||
// We want relative path so that the output files can be copied around without breaking.
|
||||
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
||||
|
||||
@@ -19,8 +19,8 @@ public partial record ExportRequest(
|
||||
Snowflake? Before,
|
||||
PartitionLimit PartitionLimit,
|
||||
MessageFilter MessageFilter,
|
||||
bool ShouldDownloadMedia,
|
||||
bool ShouldReuseMedia,
|
||||
bool ShouldDownloadAssets,
|
||||
bool ShouldReuseAssets,
|
||||
string DateFormat)
|
||||
{
|
||||
private string? _outputBaseFilePath;
|
||||
@@ -35,7 +35,7 @@ public partial record ExportRequest(
|
||||
|
||||
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
|
||||
|
||||
public string OutputMediaDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
|
||||
public string OutputAssetsDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
|
||||
}
|
||||
|
||||
public partial record ExportRequest
|
||||
|
||||
@@ -10,11 +10,19 @@ internal class ContainsMessageFilter : MessageFilter
|
||||
|
||||
public ContainsMessageFilter(string text) => _text = text;
|
||||
|
||||
// Match content within word boundaries, between spaces, or as the whole input.
|
||||
// For example, "max" shouldn't match on content "our maximum effort",
|
||||
// but should match on content "our max effort".
|
||||
// Also, "(max)" should match on content "our (max) effort", even though
|
||||
// parentheses are not considered word characters.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909
|
||||
private bool IsMatch(string? content) =>
|
||||
!string.IsNullOrWhiteSpace(content) &&
|
||||
Regex.IsMatch(
|
||||
content,
|
||||
"\\b" + Regex.Escape(_text) + "\\b",
|
||||
@"(?:\b|\s|^)" +
|
||||
Regex.Escape(_text) +
|
||||
@"(?:\b|\s|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
|
||||
);
|
||||
|
||||
|
||||
@@ -15,95 +15,107 @@ internal static class FilterGrammar
|
||||
from close in Character.EqualTo(open)
|
||||
select value;
|
||||
|
||||
private static readonly TextParser<char> FreeCharacter =
|
||||
Character.Matching(c =>
|
||||
!char.IsWhiteSpace(c) &&
|
||||
// Avoid all special tokens used by the grammar
|
||||
c is not ('(' or ')' or '"' or '\'' or '-' or '|' or '&'),
|
||||
"any character except whitespace or `(`, `)`, `\"`, `'`, `-`, `|`, `&`"
|
||||
);
|
||||
|
||||
private static readonly TextParser<string> UnquotedString =
|
||||
Parse.OneOf(EscapedCharacter, FreeCharacter).AtLeastOnce().Text();
|
||||
Parse.OneOf(
|
||||
EscapedCharacter,
|
||||
// Avoid whitespace as it's treated as an implicit 'and' operator.
|
||||
// Also avoid all special tokens used by other parsers.
|
||||
Character.ExceptIn(' ', '(', ')', '"', '\'', '-', '~', '|', '&')
|
||||
).AtLeastOnce().Text();
|
||||
|
||||
private static readonly TextParser<string> String =
|
||||
Parse.OneOf(QuotedString, UnquotedString).Named("text string");
|
||||
|
||||
private static readonly TextParser<MessageFilter> ContainsFilter =
|
||||
String.Select(v => (MessageFilter) new ContainsMessageFilter(v));
|
||||
String.Select(v => (MessageFilter)new ContainsMessageFilter(v));
|
||||
|
||||
private static readonly TextParser<MessageFilter> FromFilter = Span
|
||||
.EqualToIgnoreCase("from:")
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter) new FromMessageFilter(v))
|
||||
.Named("from:<value>");
|
||||
private static readonly TextParser<MessageFilter> FromFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("from:")
|
||||
.Try()
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter)new FromMessageFilter(v))
|
||||
.Named("from:<value>");
|
||||
|
||||
private static readonly TextParser<MessageFilter> MentionsFilter = Span
|
||||
.EqualToIgnoreCase("mentions:")
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
|
||||
.Named("mentions:<value>");
|
||||
private static readonly TextParser<MessageFilter> MentionsFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("mentions:")
|
||||
.Try()
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter)new MentionsMessageFilter(v))
|
||||
.Named("mentions:<value>");
|
||||
|
||||
private static readonly TextParser<MessageFilter> ReactionFilter = Span
|
||||
.EqualToIgnoreCase("reaction:")
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter) new ReactionMessageFilter(v))
|
||||
.Named("reaction:<value>");
|
||||
private static readonly TextParser<MessageFilter> ReactionFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("reaction:")
|
||||
.Try()
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter)new ReactionMessageFilter(v))
|
||||
.Named("reaction:<value>");
|
||||
|
||||
private static readonly TextParser<MessageFilter> HasFilter = Span
|
||||
.EqualToIgnoreCase("has:")
|
||||
.IgnoreThen(Parse.OneOf(
|
||||
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
||||
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
||||
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
|
||||
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
|
||||
))
|
||||
.Select(k => (MessageFilter) new HasMessageFilter(k))
|
||||
.Named("has:<value>");
|
||||
private static readonly TextParser<MessageFilter> HasFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("has:")
|
||||
.Try()
|
||||
.IgnoreThen(Parse.OneOf(
|
||||
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
||||
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
||||
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
|
||||
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
|
||||
))
|
||||
.Select(k => (MessageFilter)new HasMessageFilter(k))
|
||||
.Named("has:<value>");
|
||||
|
||||
private static readonly TextParser<MessageFilter> NegatedFilter = Character
|
||||
.EqualTo('-')
|
||||
.IgnoreThen(Parse.Ref(() => StandaloneFilter!))
|
||||
.Select(f => (MessageFilter) new NegatedMessageFilter(f));
|
||||
// Make sure that property-based filters like 'has:link' don't prevent text like 'hello' from being parsed.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909#issuecomment-1227575455
|
||||
private static readonly TextParser<MessageFilter> PrimitiveFilter =
|
||||
Parse.OneOf(
|
||||
FromFilter,
|
||||
MentionsFilter,
|
||||
ReactionFilter,
|
||||
HasFilter,
|
||||
ContainsFilter
|
||||
);
|
||||
|
||||
private static readonly TextParser<MessageFilter> GroupedFilter =
|
||||
from open in Character.EqualTo('(')
|
||||
from content in Parse.Ref(() => BinaryExpressionFilter!).Token()
|
||||
from content in Parse.Ref(() => ChainedFilter!).Token()
|
||||
from close in Character.EqualTo(')')
|
||||
select content;
|
||||
|
||||
private static readonly TextParser<MessageFilter> StandaloneFilter = Parse.OneOf(
|
||||
GroupedFilter,
|
||||
FromFilter,
|
||||
MentionsFilter,
|
||||
ReactionFilter,
|
||||
HasFilter,
|
||||
ContainsFilter
|
||||
);
|
||||
private static readonly TextParser<MessageFilter> NegatedFilter =
|
||||
Character
|
||||
// Dash is annoying to use from CLI due to conflicts with options, so we provide tilde as an alias
|
||||
.In('-', '~')
|
||||
.IgnoreThen(Parse.OneOf(GroupedFilter, PrimitiveFilter))
|
||||
.Select(f => (MessageFilter)new NegatedMessageFilter(f));
|
||||
|
||||
private static readonly TextParser<MessageFilter> UnaryExpressionFilter = Parse.OneOf(
|
||||
NegatedFilter,
|
||||
StandaloneFilter
|
||||
);
|
||||
|
||||
private static readonly TextParser<MessageFilter> BinaryExpressionFilter = Parse.Chain(
|
||||
Parse.OneOf(
|
||||
// Explicit operator
|
||||
Character.In('|', '&').Token().Try(),
|
||||
// Implicit operator (resolves to 'and')
|
||||
Character.WhiteSpace.AtLeastOnce().IgnoreThen(Parse.Return(' '))
|
||||
),
|
||||
UnaryExpressionFilter,
|
||||
(op, left, right) => op switch
|
||||
{
|
||||
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
||||
}
|
||||
);
|
||||
private static readonly TextParser<MessageFilter> ChainedFilter =
|
||||
Parse.Chain(
|
||||
// Operator
|
||||
Parse.OneOf(
|
||||
// Explicit operator
|
||||
Character.In('|', '&').Token().Try(),
|
||||
// Implicit operator (resolves to 'and')
|
||||
Character.EqualTo(' ').AtLeastOnce().IgnoreThen(Parse.Return(' '))
|
||||
),
|
||||
// Operand
|
||||
Parse.OneOf(
|
||||
NegatedFilter,
|
||||
GroupedFilter,
|
||||
PrimitiveFilter
|
||||
),
|
||||
// Reducer
|
||||
(op, left, right) => op switch
|
||||
{
|
||||
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
||||
}
|
||||
);
|
||||
|
||||
public static readonly TextParser<MessageFilter> Filter =
|
||||
BinaryExpressionFilter.Token().AtEnd();
|
||||
ChainedFilter.Token().AtEnd();
|
||||
}
|
||||
@@ -19,8 +19,8 @@ internal partial class CsvMessageWriter : MessageWriter
|
||||
_writer = new StreamWriter(stream);
|
||||
}
|
||||
|
||||
private string FormatMarkdown(string? markdown) =>
|
||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
|
||||
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
|
||||
|
||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
|
||||
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
||||
@@ -37,7 +37,7 @@ internal partial class CsvMessageWriter : MessageWriter
|
||||
|
||||
buffer
|
||||
.AppendIfNotEmpty(',')
|
||||
.Append(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
||||
.Append(await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
|
||||
}
|
||||
|
||||
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
|
||||
@@ -84,7 +84,7 @@ internal partial class CsvMessageWriter : MessageWriter
|
||||
await _writer.WriteAsync(',');
|
||||
|
||||
// Message content
|
||||
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content)));
|
||||
await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content)));
|
||||
await _writer.WriteAsync(',');
|
||||
|
||||
// Attachments
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||
|
||||
internal static class MessageExtensions
|
||||
{
|
||||
// Message content is hidden if it's a link to an embedded media
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||
public static bool IsContentHidden(this Message message)
|
||||
{
|
||||
if (message.Embeds.Count != 1)
|
||||
return false;
|
||||
|
||||
var embed = message.Embeds[0];
|
||||
|
||||
return
|
||||
string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) &&
|
||||
embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,20 @@
|
||||
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
|
||||
|
||||
@{
|
||||
ValueTask<string> ResolveAssetUrlAsync(string url) =>
|
||||
Model.ExportContext.ResolveAssetUrlAsync(url);
|
||||
|
||||
string FormatDate(DateTimeOffset date) =>
|
||||
Model.ExportContext.FormatDate(date);
|
||||
|
||||
ValueTask<string> FormatMarkdownAsync(string markdown) =>
|
||||
Model.FormatMarkdownAsync(markdown);
|
||||
|
||||
ValueTask<string> FormatEmbedMarkdownAsync(string markdown) =>
|
||||
Model.FormatMarkdownAsync(markdown, false);
|
||||
|
||||
var firstMessage = Model.Messages.First();
|
||||
|
||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
|
||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||
|
||||
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
||||
|
||||
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
|
||||
|
||||
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
|
||||
@@ -48,438 +52,478 @@
|
||||
{
|
||||
var isFirst = i == 0;
|
||||
|
||||
// Hide message content if it only contains a link to an image which is embedded, and nothing else
|
||||
var isContentHidden =
|
||||
message.Embeds.Count == 1 &&
|
||||
message.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.Embeds.Single())?.Url;
|
||||
|
||||
var isReferencedContentHidden =
|
||||
message.ReferencedMessage is not null &&
|
||||
message.ReferencedMessage.Embeds.Count == 1 &&
|
||||
message.ReferencedMessage.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.ReferencedMessage.Embeds.Single())?.Url;
|
||||
|
||||
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
|
||||
<div class="chatlog__message">
|
||||
@{/* Left side */}
|
||||
<div class="chatlog__message-aside">
|
||||
@if (isFirst)
|
||||
{
|
||||
// Reference symbol
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
<div class="chatlog__reference-symbol"></div>
|
||||
}
|
||||
@{/* System notification */}
|
||||
@if (message.Kind.IsSystemNotification())
|
||||
{
|
||||
// System notifications are grouped even if the message author is different.
|
||||
// That's why we have to update the user values with the author of the current message.
|
||||
userMember = Model.ExportContext.TryGetMember(message.Author.Id);
|
||||
userColor = Model.ExportContext.TryGetUserColor(message.Author.Id);
|
||||
userNick = message.Author.IsBot
|
||||
? message.Author.Name
|
||||
: userMember?.Nick ?? message.Author.Name;
|
||||
|
||||
// Avatar
|
||||
<img class="chatlog__avatar" src="@await ResolveUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
|
||||
}
|
||||
</div>
|
||||
<div class="chatlog__message-aside">
|
||||
<svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
|
||||
</div>
|
||||
|
||||
@{/* Right side */}
|
||||
<div class="chatlog__message-primary">
|
||||
@if (isFirst)
|
||||
{
|
||||
// Reference
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
<div class="chatlog__reference">
|
||||
@if (message.ReferencedMessage is not null)
|
||||
{
|
||||
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
|
||||
<div class="chatlog__reference-content">
|
||||
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !isReferencedContentHidden)
|
||||
{
|
||||
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore-->
|
||||
}
|
||||
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
|
||||
{
|
||||
<em>Click to see attachment</em>
|
||||
<span>🖼️</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<em>Click to see original message</em>
|
||||
}
|
||||
</span>
|
||||
<div class="chatlog__message-primary">
|
||||
@{/* Author name */}
|
||||
<span class="chatlog__system-notification-author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||
|
||||
@if (message.ReferencedMessage.EditedTimestamp is not null)
|
||||
{
|
||||
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__reference-unknown">
|
||||
Original message was deleted or could not be loaded.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// Header
|
||||
<div class="chatlog__header">
|
||||
@{/* Author name */}
|
||||
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||
|
||||
@{/* Bot label */}
|
||||
@if (message.Author.IsBot)
|
||||
@{/* System notification content */}
|
||||
<span class="chatlog__system-notification-content">
|
||||
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
|
||||
{
|
||||
<span class="chatlog__bot-label">BOT</span>
|
||||
}
|
||||
|
||||
@{/* Timestamp */}
|
||||
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Content */}
|
||||
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
|
||||
{
|
||||
<div class="chatlog__content chatlog__markdown">
|
||||
@{/* Text */}
|
||||
@if (!isContentHidden)
|
||||
{
|
||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
|
||||
}
|
||||
|
||||
@{/* Edited timestamp */}
|
||||
@if (message.EditedTimestamp is not null)
|
||||
{
|
||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Attachments */}
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
|
||||
@{/* Spoiler caption */}
|
||||
@if (attachment.IsSpoiler)
|
||||
{
|
||||
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
|
||||
}
|
||||
|
||||
@{/* Attachment preview */}
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||
</a>
|
||||
}
|
||||
else if (attachment.IsVideo)
|
||||
{
|
||||
<video class="chatlog__attachment-media" controls>
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||
</video>
|
||||
}
|
||||
else if (attachment.IsAudio)
|
||||
{
|
||||
<audio class="chatlog__attachment-media" controls>
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||
</audio>
|
||||
<span> pinned</span>
|
||||
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId"> a message</a>
|
||||
<span> to this channel.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__attachment-generic">
|
||||
<svg class="chatlog__attachment-generic-icon">
|
||||
<use href="#attachment-icon"/>
|
||||
</svg>
|
||||
<div class="chatlog__attachment-generic-name">
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
@attachment.FileName
|
||||
</a>
|
||||
</div>
|
||||
<div class="chatlog__attachment-generic-size">
|
||||
@attachment.FileSize
|
||||
</div>
|
||||
<span>@message.Content.ToLowerInvariant()</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@{/* Timestamp */}
|
||||
<span class="chatlog__system-notification-timestamp">
|
||||
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
// Regular message
|
||||
else
|
||||
{
|
||||
<div class="chatlog__message-aside">
|
||||
@if (isFirst)
|
||||
{
|
||||
// Reference symbol
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
<div class="chatlog__reference-symbol"></div>
|
||||
}
|
||||
|
||||
// Avatar
|
||||
<img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="chatlog__message-primary">
|
||||
@if (isFirst)
|
||||
{
|
||||
// Reference
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
<div class="chatlog__reference">
|
||||
@if (message.ReferencedMessage is not null)
|
||||
{
|
||||
<img class="chatlog__reference-avatar" src="@await ResolveAssetUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
|
||||
<div class="chatlog__reference-content">
|
||||
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
|
||||
{
|
||||
<!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(message.ReferencedMessage.Content))<!--/wmm:ignore-->
|
||||
}
|
||||
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
|
||||
{
|
||||
<em>Click to see attachment</em>
|
||||
<span>🖼️</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<em>Click to see original message</em>
|
||||
}
|
||||
</span>
|
||||
|
||||
@if (message.ReferencedMessage.EditedTimestamp is not null)
|
||||
{
|
||||
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__reference-unknown">
|
||||
Original message was deleted or could not be loaded.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embeds */}
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
// Plain image embed
|
||||
if (embed.TryGetPlainImage() is { } plainImageEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
<a href="@await ResolveUrlAsync(plainImageEmbed.Url)">
|
||||
<img class="chatlog__embed-plainimage" src="@await ResolveUrlAsync(plainImageEmbed.Url)" alt="Embedded image" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
// Spotify embed
|
||||
else if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
<div class="chatlog__embed-spotify-container">
|
||||
<iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
// YouTube embed
|
||||
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@if (embed.Color is not null)
|
||||
// Header
|
||||
<div class="chatlog__header">
|
||||
@{/* Author name */}
|
||||
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||
|
||||
@{/* Bot label */}
|
||||
@if (message.Author.IsBot)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||
<span class="chatlog__bot-label">BOT</span>
|
||||
}
|
||||
|
||||
@{/* Timestamp */}
|
||||
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Content */}
|
||||
@if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
|
||||
{
|
||||
<div class="chatlog__content chatlog__markdown">
|
||||
@{/* Text */}
|
||||
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
|
||||
{
|
||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
|
||||
}
|
||||
|
||||
@{/* Edited timestamp */}
|
||||
@if (message.EditedTimestamp is not null)
|
||||
{
|
||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Attachments */}
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
|
||||
@{/* Spoiler caption */}
|
||||
@if (attachment.IsSpoiler)
|
||||
{
|
||||
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
|
||||
}
|
||||
|
||||
@{/* Attachment preview */}
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-media" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||
</a>
|
||||
}
|
||||
else if (attachment.IsVideo)
|
||||
{
|
||||
<video class="chatlog__attachment-media" controls>
|
||||
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||
</video>
|
||||
}
|
||||
else if (attachment.IsAudio)
|
||||
{
|
||||
<audio class="chatlog__attachment-media" controls>
|
||||
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||
</audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
<div class="chatlog__attachment-generic">
|
||||
<svg class="chatlog__attachment-generic-icon">
|
||||
<use href="#attachment-icon"/>
|
||||
</svg>
|
||||
<div class="chatlog__attachment-generic-name">
|
||||
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||
@attachment.FileName
|
||||
</a>
|
||||
</div>
|
||||
<div class="chatlog__attachment-generic-size">
|
||||
@attachment.FileSize
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@{/* Embed author */}
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
}
|
||||
@{/* Embeds */}
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
// Spotify embed
|
||||
if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
<div class="chatlog__embed-spotify-container">
|
||||
<iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
// YouTube embed
|
||||
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@{/* Embed author */}
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
{
|
||||
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||
}
|
||||
@{/* Video player */}
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Video player */}
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
// Generic embed
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
}
|
||||
}
|
||||
// Generic image embed
|
||||
else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
<a href="@await ResolveAssetUrlAsync(embed.Url)">
|
||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
// Generic gifv embed
|
||||
else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Video?.Url))
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-gifv" loop width="@embed.Video.Width" height="@embed.Video.Height" onmouseover="this.play()" onmouseout="this.pause()">
|
||||
<source src="@await ResolveAssetUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
|
||||
</video>
|
||||
</div>
|
||||
}
|
||||
// Rich embed
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
}
|
||||
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@{/* Embed author */}
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@{/* Embed author */}
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
{
|
||||
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
@{/* Embed description */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed fields */}
|
||||
@if (embed.Fields.Any())
|
||||
{
|
||||
<div class="chatlog__embed-fields">
|
||||
@foreach (var field in embed.Fields)
|
||||
{
|
||||
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
|
||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
{
|
||||
<div class="chatlog__embed-field-name">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(field.Name))<!--/wmm:ignore--></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
<div class="chatlog__embed-field-value">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@{/* Embed content */}
|
||||
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed description */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Description))<!--/wmm:ignore--></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed fields */}
|
||||
@if (embed.Fields.Any())
|
||||
{
|
||||
<div class="chatlog__embed-fields">
|
||||
@foreach (var field in embed.Fields)
|
||||
{
|
||||
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
|
||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
{
|
||||
<div class="chatlog__embed-field-name">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Name))<!--/wmm:ignore--></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
<div class="chatlog__embed-field-value">
|
||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Value))<!--/wmm:ignore--></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@{/* Embed content */}
|
||||
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
|
||||
@{/* Embed images */}
|
||||
@if (embed.Images.Any())
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
||||
</a>
|
||||
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
|
||||
@foreach (var image in embed.Images)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(image.Url))
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed footer & icon */}
|
||||
@if (embed.Footer is not null || embed.Timestamp is not null)
|
||||
{
|
||||
<div class="chatlog__embed-footer">
|
||||
@{/* Footer icon */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@{/* Footer text */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@{/* Embed timestamp */}
|
||||
@if (embed.Timestamp is not null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@{/* Embed images */}
|
||||
@if (embed.Images.Any())
|
||||
{
|
||||
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
|
||||
@foreach (var image in embed.Images)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(image.Url))
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed footer & icon */}
|
||||
@if (embed.Footer is not null || embed.Timestamp is not null)
|
||||
{
|
||||
<div class="chatlog__embed-footer">
|
||||
@{/* Footer icon */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@{/* Footer text */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@{/* Embed timestamp */}
|
||||
@if (embed.Timestamp is not null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@{/* Stickers */}
|
||||
@foreach (var sticker in message.Stickers)
|
||||
{
|
||||
<div class="chatlog__sticker" title="@sticker.Name">
|
||||
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
|
||||
{
|
||||
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||
}
|
||||
else if (sticker.Format == StickerFormat.Lottie)
|
||||
{
|
||||
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@{/* Stickers */}
|
||||
@foreach (var sticker in message.Stickers)
|
||||
{
|
||||
<div class="chatlog__sticker" title="@sticker.Name">
|
||||
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
|
||||
{
|
||||
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||
}
|
||||
else if (sticker.Format == StickerFormat.Lottie)
|
||||
{
|
||||
<div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Message reactions */}
|
||||
@if (message.Reactions.Any())
|
||||
{
|
||||
<div class="chatlog__reactions">
|
||||
@foreach (var reaction in message.Reactions)
|
||||
{
|
||||
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
||||
<img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
||||
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@{/* Message reactions */}
|
||||
@if (message.Reactions.Any())
|
||||
{
|
||||
<div class="chatlog__reactions">
|
||||
@foreach (var reaction in message.Reactions)
|
||||
{
|
||||
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
||||
<img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveAssetUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
||||
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
|
||||
@@ -16,6 +17,6 @@ internal class MessageGroupTemplateContext
|
||||
Messages = messages;
|
||||
}
|
||||
|
||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
||||
public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
|
||||
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
|
||||
}
|
||||
@@ -14,11 +14,14 @@
|
||||
string GetFontUrl(int weight) =>
|
||||
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff";
|
||||
|
||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken);
|
||||
ValueTask<string> ResolveAssetUrlAsync(string url) =>
|
||||
Model.ExportContext.ResolveAssetUrlAsync(url, CancellationToken);
|
||||
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
string FormatDate(DateTimeOffset date) =>
|
||||
Model.ExportContext.FormatDate(date);
|
||||
|
||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||
ValueTask<string> FormatMarkdownAsync(string markdown) =>
|
||||
Model.FormatMarkdownAsync(markdown);
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -32,31 +35,31 @@
|
||||
@{/* Styling */}
|
||||
<style>
|
||||
@@font-face {
|
||||
src: url(@await ResolveUrlAsync(GetFontUrl(300)));
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(300)));
|
||||
font-family: Whitney;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveUrlAsync(GetFontUrl(400)));
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(400)));
|
||||
font-family: Whitney;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveUrlAsync(GetFontUrl(500)));
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(500)));
|
||||
font-family: Whitney;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveUrlAsync(GetFontUrl(600)));
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(600)));
|
||||
font-family: Whitney;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveUrlAsync(GetFontUrl(700)));
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(700)));
|
||||
font-family: Whitney;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -69,6 +72,7 @@
|
||||
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 17px;
|
||||
font-weight: @Themed("400", "500");
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -254,6 +258,38 @@
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
.chatlog__system-notification-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.chatlog__system-notification-author {
|
||||
font-weight: @Themed("500", "600");
|
||||
color: @Themed("#ffffff", "#2f3136");
|
||||
}
|
||||
|
||||
.chatlog__system-notification-content {
|
||||
color: @Themed("#96989d", "#5e6772")
|
||||
}
|
||||
|
||||
.chatlog__system-notification-link {
|
||||
font-weight: 500;
|
||||
color: @Themed("#ffffff", "#2f3136");
|
||||
}
|
||||
|
||||
.chatlog__system-notification-timestamp {
|
||||
margin-left: 0.3rem;
|
||||
color: @Themed("#a3a6aa", "#5e6772");
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
direction: ltr;
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
.chatlog__system-notification-timestamp a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chatlog__header {
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
@@ -286,7 +322,7 @@
|
||||
}
|
||||
|
||||
.chatlog__timestamp a {
|
||||
color: @Themed("#a3a6aa", "#5e6772");
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chatlog__content {
|
||||
@@ -540,7 +576,14 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chatlog__embed-plainimage {
|
||||
.chatlog__embed-generic-image {
|
||||
max-width: 45vw;
|
||||
max-height: 500px;
|
||||
vertical-align: top;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed-generic-gifv {
|
||||
max-width: 45vw;
|
||||
max-height: 500px;
|
||||
vertical-align: top;
|
||||
@@ -712,8 +755,8 @@
|
||||
</style>
|
||||
|
||||
@{/* Syntax highlighting */}
|
||||
<link rel="stylesheet" href="@await ResolveUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{Model.ThemeName.ToLowerInvariant()}.min.css")">
|
||||
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
|
||||
<link rel="stylesheet" href="@await ResolveAssetUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{Model.ThemeName.ToLowerInvariant()}.min.css")">
|
||||
<script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e));
|
||||
@@ -721,22 +764,21 @@
|
||||
</script>
|
||||
|
||||
@{/* Lottie animation support */}
|
||||
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
|
||||
<script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
|
||||
const imageDataUrl = e.getAttribute('data-source');
|
||||
|
||||
const anim = lottie.loadAnimation({
|
||||
container: e,
|
||||
renderer: 'svg',
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
path: imageDataUrl
|
||||
container: e,
|
||||
renderer: 'svg',
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
path: e.getAttribute('data-source')
|
||||
});
|
||||
|
||||
anim.addEventListener('data_failed', () =>
|
||||
e.innerHTML = '<strong>[Sticker cannot be rendered]</strong>'
|
||||
anim.addEventListener(
|
||||
'data_failed',
|
||||
() => e.innerHTML = '<strong>[Sticker cannot be rendered]</strong>'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -745,7 +787,7 @@
|
||||
@{/* Scripts */}
|
||||
<script>
|
||||
function scrollToMessage(event, id) {
|
||||
var element = document.getElementById('chatlog__message-container-' + id);
|
||||
const element = document.getElementById('chatlog__message-container-' + id);
|
||||
if (!element)
|
||||
return;
|
||||
|
||||
@@ -757,9 +799,10 @@
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
window.setTimeout(function() {
|
||||
element.classList.remove('chatlog__message-container--highlighted');
|
||||
}, 2000);
|
||||
window.setTimeout(
|
||||
() => element.classList.remove('chatlog__message-container--highlighted'),
|
||||
2000
|
||||
);
|
||||
}
|
||||
|
||||
function showSpoiler(event, element) {
|
||||
@@ -779,20 +822,31 @@
|
||||
</script>
|
||||
|
||||
@{/* Icons */}
|
||||
<svg style="display: none">
|
||||
<symbol id="attachment-icon" viewBox="0 0 720 960">
|
||||
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z"/>
|
||||
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/>
|
||||
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/>
|
||||
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/>
|
||||
</symbol>
|
||||
<svg style="display: none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<symbol id="attachment-icon" viewBox="0 0 720 960">
|
||||
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" />
|
||||
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" />
|
||||
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" />
|
||||
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" />
|
||||
</symbol>
|
||||
<symbol id="channel-pinned-message-icon" viewBox="0 0 18 18">
|
||||
<path fill="#b9bbbe" d="m16.908 8.39684-8.29587-8.295827-1.18584 1.184157 1.18584 1.18584-4.14834 4.1475v.00167l-1.18583-1.18583-1.185 1.18583 3.55583 3.55502-4.740831 4.74 1.185001 1.185 4.74083-4.74 3.55581 3.555 1.185-1.185-1.185-1.185 4.1475-4.14836h.0009l1.185 1.185z" />
|
||||
</symbol>
|
||||
<symbol id="call-icon" viewBox="0 0 18 18">
|
||||
<path fill="#3ba55c" fill-rule="evenodd" d="M17.7163041 15.36645368c-.0190957.02699568-1.9039523 2.6680735-2.9957762 2.63320406-3.0676659-.09785935-6.6733809-3.07188394-9.15694343-5.548738C3.08002193 9.9740657.09772497 6.3791404 0 3.3061316v-.024746C0 2.2060575 2.61386252.3152347 2.64082114.2972376c.7110335-.4971705 1.4917101-.3149497 1.80959713.1372281.19320342.2744561 2.19712724 3.2811005 2.42290565 3.6489167.09884826.1608492.14714912.3554431.14714912.5702838 0 .2744561-.07975258.5770327-.23701117.8751101-.1527655.2902036-.65262318 1.1664385-.89862055 1.594995.2673396.3768148.94804468 1.26429792 2.351016 2.66357424 1.39173858 1.39027775 2.28923588 2.07641807 2.67002628 2.34187563.4302146-.2452108 1.3086162-.74238132 1.5972981-.89423205.5447887-.28682915 1.0907006-.31944893 1.4568885-.08661115.3459689.2182151 3.3383754 2.21027167 3.6225641 2.41611376.2695862.19234426.4144887.5399137.4144887.91672846 0 .2969525-.089862.61190215-.2808189.88523346" />
|
||||
</symbol>
|
||||
<symbol id="guild-member-join-icon" viewBox="0 0 18 18">
|
||||
<path fill="#3ba55c" d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="preamble">
|
||||
<div class="preamble__guild-icon-container">
|
||||
<img class="preamble__guild-icon" src="@await ResolveUrlAsync(Model.ExportContext.Request.Guild.IconUrl)" alt="Guild icon" loading="lazy">
|
||||
<img class="preamble__guild-icon" src="@await ResolveAssetUrlAsync(Model.ExportContext.Request.Guild.IconUrl)" alt="Guild icon" loading="lazy">
|
||||
</div>
|
||||
<div class="preamble__entries-container">
|
||||
<div class="preamble__entry">@Model.ExportContext.Request.Guild.Name</div>
|
||||
@@ -800,7 +854,7 @@
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic))
|
||||
{
|
||||
<div class="preamble__entry preamble__entry--small">@Raw(FormatMarkdown(Model.ExportContext.Request.Channel.Topic))</div>
|
||||
<div class="preamble__entry preamble__entry--small">@Raw(await FormatMarkdownAsync(Model.ExportContext.Request.Channel.Topic))</div>
|
||||
}
|
||||
|
||||
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||
|
||||
@@ -14,6 +15,6 @@ internal class PreambleTemplateContext
|
||||
ThemeName = themeName;
|
||||
}
|
||||
|
||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
||||
public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
|
||||
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
|
||||
}
|
||||
@@ -25,17 +25,46 @@ internal class HtmlMessageWriter : MessageWriter
|
||||
_themeName = themeName;
|
||||
}
|
||||
|
||||
private bool CanJoinGroup(Message message) =>
|
||||
// First message in the group can always join
|
||||
_messageGroup.LastOrDefault() is not { } lastMessage ||
|
||||
// Must be from the same author
|
||||
lastMessage.Author.Id == message.Author.Id &&
|
||||
// Author's name must not have changed between messages
|
||||
string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) &&
|
||||
// Duration between messages must be 7 minutes or less
|
||||
(message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 &&
|
||||
// Other message must not be a reply
|
||||
message.Reference is null;
|
||||
private bool CanJoinGroup(Message message)
|
||||
{
|
||||
// If the group is empty, any message can join it
|
||||
if (_messageGroup.LastOrDefault() is not { } lastMessage)
|
||||
return true;
|
||||
|
||||
// Reply messages cannot join existing groups because they need to appear first
|
||||
if (message.Kind == MessageKind.Reply)
|
||||
return false;
|
||||
|
||||
// Grouping for system notifications
|
||||
if (message.Kind.IsSystemNotification())
|
||||
{
|
||||
// Can only be grouped with other system notifications
|
||||
if (!lastMessage.Kind.IsSystemNotification())
|
||||
return false;
|
||||
}
|
||||
// Grouping for normal messages
|
||||
else
|
||||
{
|
||||
// Can only be grouped with other normal messages
|
||||
if (lastMessage.Kind.IsSystemNotification())
|
||||
return false;
|
||||
|
||||
// Messages must be within 7 minutes of each other
|
||||
if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7)
|
||||
return false;
|
||||
|
||||
// Messages must be from the same author
|
||||
if (message.Author.Id != lastMessage.Author.Id)
|
||||
return false;
|
||||
|
||||
// If the user changed their name after the last message, their new messages
|
||||
// cannot join an existing group.
|
||||
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Use <!--wmm:ignore--> to preserve blocks of code inside the templates
|
||||
private string Minify(string html) => _minifier
|
||||
@@ -77,7 +106,7 @@ internal class HtmlMessageWriter : MessageWriter
|
||||
await base.WriteMessageAsync(message, cancellationToken);
|
||||
|
||||
// If the message can be grouped, buffer it for now
|
||||
if (CanJoinGroup( message))
|
||||
if (CanJoinGroup(message))
|
||||
{
|
||||
_messageGroup.Add(message);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ internal class JsonMessageWriter : MessageWriter
|
||||
});
|
||||
}
|
||||
|
||||
private string FormatMarkdown(string? markdown) =>
|
||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
|
||||
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
|
||||
|
||||
private async ValueTask WriteAttachmentAsync(
|
||||
Attachment attachment,
|
||||
@@ -39,7 +39,7 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", attachment.Id.ToString());
|
||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
||||
_writer.WriteString("url", await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
|
||||
_writer.WriteString("fileName", attachment.FileName);
|
||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||
|
||||
@@ -57,7 +57,12 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteString("url", embedAuthor.Url);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken));
|
||||
{
|
||||
_writer.WriteString(
|
||||
"iconUrl",
|
||||
await Context.ResolveAssetUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken)
|
||||
);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
@@ -70,7 +75,12 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteStartObject();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken));
|
||||
{
|
||||
_writer.WriteString(
|
||||
"url",
|
||||
await Context.ResolveAssetUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken)
|
||||
);
|
||||
}
|
||||
|
||||
_writer.WriteNumber("width", embedImage.Width);
|
||||
_writer.WriteNumber("height", embedImage.Height);
|
||||
@@ -88,7 +98,12 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteString("text", embedFooter.Text);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken));
|
||||
{
|
||||
_writer.WriteString(
|
||||
"iconUrl",
|
||||
await Context.ResolveAssetUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken)
|
||||
);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
@@ -100,8 +115,8 @@ internal class JsonMessageWriter : MessageWriter
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("name", FormatMarkdown(embedField.Name));
|
||||
_writer.WriteString("value", FormatMarkdown(embedField.Value));
|
||||
_writer.WriteString("name", await FormatMarkdownAsync(embedField.Name));
|
||||
_writer.WriteString("value", await FormatMarkdownAsync(embedField.Value));
|
||||
_writer.WriteBoolean("isInline", embedField.IsInline);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
@@ -114,10 +129,10 @@ internal class JsonMessageWriter : MessageWriter
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("title", FormatMarkdown(embed.Title));
|
||||
_writer.WriteString("title", await FormatMarkdownAsync(embed.Title));
|
||||
_writer.WriteString("url", embed.Url);
|
||||
_writer.WriteString("timestamp", embed.Timestamp);
|
||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
||||
_writer.WriteString("description", await FormatMarkdownAsync(embed.Description));
|
||||
|
||||
if (embed.Color is not null)
|
||||
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||
@@ -176,7 +191,7 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteString("id", sticker.Id.ToString());
|
||||
_writer.WriteString("name", sticker.Name);
|
||||
_writer.WriteString("format", sticker.Format.ToString());
|
||||
_writer.WriteString("sourceUrl", await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
|
||||
_writer.WriteString("sourceUrl", await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken));
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
@@ -193,7 +208,7 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteString("id", reaction.Emoji.Id.ToString());
|
||||
_writer.WriteString("name", reaction.Emoji.Name);
|
||||
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
||||
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
||||
_writer.WriteString("imageUrl", await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
||||
_writer.WriteEndObject();
|
||||
|
||||
_writer.WriteNumber("count", reaction.Count);
|
||||
@@ -227,7 +242,7 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteStartObject("guild");
|
||||
_writer.WriteString("id", Context.Request.Guild.Id.ToString());
|
||||
_writer.WriteString("name", Context.Request.Guild.Name);
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl, cancellationToken));
|
||||
_writer.WriteString("iconUrl", await Context.ResolveAssetUrlAsync(Context.Request.Guild.IconUrl, cancellationToken));
|
||||
_writer.WriteEndObject();
|
||||
|
||||
// Channel
|
||||
@@ -268,7 +283,7 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteBoolean("isPinned", message.IsPinned);
|
||||
|
||||
// Content
|
||||
_writer.WriteString("content", FormatMarkdown(message.Content));
|
||||
_writer.WriteString("content", await FormatMarkdownAsync(message.Content));
|
||||
|
||||
// Author
|
||||
_writer.WriteStartObject("author");
|
||||
@@ -278,7 +293,7 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name);
|
||||
_writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
|
||||
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
||||
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl, cancellationToken));
|
||||
_writer.WriteString("avatarUrl", await Context.ResolveAssetUrlAsync(message.Author.AvatarUrl, cancellationToken));
|
||||
_writer.WriteEndObject();
|
||||
|
||||
// Attachments
|
||||
|
||||
+34
-23
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
@@ -23,15 +24,15 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
_isJumbo = isJumbo;
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitText(TextNode text)
|
||||
protected override async ValueTask<MarkdownNode> VisitTextAsync(TextNode text)
|
||||
{
|
||||
_buffer.Append(HtmlEncode(text.Text));
|
||||
return base.VisitText(text);
|
||||
return await base.VisitTextAsync(text);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitFormatting(FormattingNode formatting)
|
||||
protected override async ValueTask<MarkdownNode> VisitFormattingAsync(FormattingNode formatting)
|
||||
{
|
||||
var (tagOpen, tagClose) = formatting.Kind switch
|
||||
var (openingTag, closingTag) = formatting.Kind switch
|
||||
{
|
||||
FormattingKind.Bold => (
|
||||
"<strong>",
|
||||
@@ -66,24 +67,24 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
|
||||
};
|
||||
|
||||
_buffer.Append(tagOpen);
|
||||
var result = base.VisitFormatting(formatting);
|
||||
_buffer.Append(tagClose);
|
||||
_buffer.Append(openingTag);
|
||||
var result = await base.VisitFormattingAsync(formatting);
|
||||
_buffer.Append(closingTag);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
||||
protected override async ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock)
|
||||
{
|
||||
_buffer
|
||||
.Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
|
||||
.Append(HtmlEncode(inlineCodeBlock.Code))
|
||||
.Append("</code>");
|
||||
|
||||
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
||||
return await base.VisitInlineCodeBlockAsync(inlineCodeBlock);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
|
||||
protected override async ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(MultiLineCodeBlockNode multiLineCodeBlock)
|
||||
{
|
||||
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
||||
? $"language-{multiLineCodeBlock.Language}"
|
||||
@@ -94,10 +95,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
||||
.Append("</code>");
|
||||
|
||||
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
||||
return await base.VisitMultiLineCodeBlockAsync(multiLineCodeBlock);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitLink(LinkNode link)
|
||||
protected override async ValueTask<MarkdownNode> VisitLinkAsync(LinkNode link)
|
||||
{
|
||||
// Try to extract message ID if the link refers to a Discord message
|
||||
var linkedMessageId = Regex.Match(
|
||||
@@ -111,24 +112,31 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
: $"<a href=\"{HtmlEncode(link.Url)}\">"
|
||||
);
|
||||
|
||||
var result = base.VisitLink(link);
|
||||
var result = await base.VisitLinkAsync(link);
|
||||
_buffer.Append("</a>");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||
protected override async ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji)
|
||||
{
|
||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
|
||||
|
||||
_buffer
|
||||
.Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
||||
_buffer.Append(
|
||||
$"<img " +
|
||||
$"loading=\"lazy\" " +
|
||||
$"class=\"chatlog__emoji {jumboClass}\" " +
|
||||
$"alt=\"{emoji.Name}\" " +
|
||||
$"title=\"{emoji.Code}\" " +
|
||||
$"src=\"{await _context.ResolveAssetUrlAsync(emojiImageUrl)}\"" +
|
||||
$">"
|
||||
);
|
||||
|
||||
return base.VisitEmoji(emoji);
|
||||
return await base.VisitEmojiAsync(emoji);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||
protected override async ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention)
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
{
|
||||
@@ -183,10 +191,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
.Append("</span>");
|
||||
}
|
||||
|
||||
return base.VisitMention(mention);
|
||||
return await base.VisitMentionAsync(mention);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
||||
protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp)
|
||||
{
|
||||
var dateString = timestamp.Date is not null
|
||||
? _context.FormatDate(timestamp.Date.Value)
|
||||
@@ -202,7 +210,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
.Append(HtmlEncode(dateString))
|
||||
.Append("</span>");
|
||||
|
||||
return base.VisitUnixTimestamp(timestamp);
|
||||
return await base.VisitUnixTimestampAsync(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +218,10 @@ internal partial class HtmlMarkdownVisitor
|
||||
{
|
||||
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
|
||||
|
||||
public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true)
|
||||
public static async ValueTask<string> FormatAsync(
|
||||
ExportContext context,
|
||||
string markdown,
|
||||
bool isJumboAllowed = true)
|
||||
{
|
||||
var nodes = MarkdownParser.Parse(markdown);
|
||||
|
||||
@@ -220,7 +231,7 @@ internal partial class HtmlMarkdownVisitor
|
||||
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
new HtmlMarkdownVisitor(context, buffer, isJumbo).Visit(nodes);
|
||||
await new HtmlMarkdownVisitor(context, buffer, isJumbo).VisitAsync(nodes);
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
|
||||
+11
-10
@@ -1,4 +1,5 @@
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -16,13 +17,13 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
_buffer = buffer;
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitText(TextNode text)
|
||||
protected override async ValueTask<MarkdownNode> VisitTextAsync(TextNode text)
|
||||
{
|
||||
_buffer.Append(text.Text);
|
||||
return base.VisitText(text);
|
||||
return await base.VisitTextAsync(text);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||
protected override async ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji)
|
||||
{
|
||||
_buffer.Append(
|
||||
emoji.IsCustomEmoji
|
||||
@@ -30,10 +31,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
: emoji.Name
|
||||
);
|
||||
|
||||
return base.VisitEmoji(emoji);
|
||||
return await base.VisitEmojiAsync(emoji);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||
protected override async ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention)
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
{
|
||||
@@ -69,10 +70,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
_buffer.Append($"@{name}");
|
||||
}
|
||||
|
||||
return base.VisitMention(mention);
|
||||
return await base.VisitMentionAsync(mention);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
||||
protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp)
|
||||
{
|
||||
_buffer.Append(
|
||||
timestamp.Date is not null
|
||||
@@ -80,18 +81,18 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
: "Invalid date"
|
||||
);
|
||||
|
||||
return base.VisitUnixTimestamp(timestamp);
|
||||
return await base.VisitUnixTimestampAsync(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class PlainTextMarkdownVisitor
|
||||
{
|
||||
public static string Format(ExportContext context, string markdown)
|
||||
public static async ValueTask<string> FormatAsync(ExportContext context, string markdown)
|
||||
{
|
||||
var nodes = MarkdownParser.ParseMinimal(markdown);
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
new PlainTextMarkdownVisitor(context, buffer).Visit(nodes);
|
||||
await new PlainTextMarkdownVisitor(context, buffer).VisitAsync(nodes);
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ internal class PlainTextMessageWriter : MessageWriter
|
||||
_writer = new StreamWriter(stream);
|
||||
}
|
||||
|
||||
private string FormatMarkdown(string? markdown) =>
|
||||
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
|
||||
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
|
||||
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
|
||||
|
||||
private async ValueTask WriteMessageHeaderAsync(Message message)
|
||||
{
|
||||
@@ -48,7 +48,7 @@ internal class PlainTextMessageWriter : MessageWriter
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
|
||||
await _writer.WriteLineAsync(await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
|
||||
}
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
@@ -71,27 +71,41 @@ internal class PlainTextMessageWriter : MessageWriter
|
||||
await _writer.WriteLineAsync(embed.Url);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
await _writer.WriteLineAsync(FormatMarkdown(embed.Title));
|
||||
await _writer.WriteLineAsync(await FormatMarkdownAsync(embed.Title));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
await _writer.WriteLineAsync(FormatMarkdown(embed.Description));
|
||||
await _writer.WriteLineAsync(await FormatMarkdownAsync(embed.Description));
|
||||
|
||||
foreach (var field in embed.Fields)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
|
||||
await _writer.WriteLineAsync(await FormatMarkdownAsync(field.Name));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
|
||||
await _writer.WriteLineAsync(await FormatMarkdownAsync(field.Value));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken));
|
||||
{
|
||||
await _writer.WriteLineAsync(
|
||||
await Context.ResolveAssetUrlAsync(
|
||||
embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url,
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var image in embed.Images)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(image.Url))
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(image.ProxyUrl ?? image.Url, cancellationToken));
|
||||
{
|
||||
await _writer.WriteLineAsync(
|
||||
await Context.ResolveAssetUrlAsync(
|
||||
image.ProxyUrl ?? image.Url,
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
@@ -114,7 +128,9 @@ internal class PlainTextMessageWriter : MessageWriter
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
|
||||
await _writer.WriteLineAsync(
|
||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||
);
|
||||
}
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
@@ -174,7 +190,7 @@ internal class PlainTextMessageWriter : MessageWriter
|
||||
|
||||
// Content
|
||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
|
||||
await _writer.WriteLineAsync(await FormatMarkdownAsync(message.Content));
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@ namespace DiscordChatExporter.Core.Markdown;
|
||||
internal record EmojiNode(
|
||||
// Only present on custom emoji
|
||||
Snowflake? Id,
|
||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||
string Name,
|
||||
bool IsAnimated) : MarkdownNode
|
||||
{
|
||||
public bool IsCustomEmoji => Id is not null;
|
||||
|
||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
public string Code => IsCustomEmoji
|
||||
? Name
|
||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
internal abstract class MarkdownVisitor
|
||||
{
|
||||
protected virtual MarkdownNode VisitText(TextNode text) =>
|
||||
text;
|
||||
protected virtual ValueTask<MarkdownNode> VisitTextAsync(TextNode text) =>
|
||||
new(text);
|
||||
|
||||
protected virtual MarkdownNode VisitFormatting(FormattingNode formatting)
|
||||
protected virtual async ValueTask<MarkdownNode> VisitFormattingAsync(FormattingNode formatting)
|
||||
{
|
||||
Visit(formatting.Children);
|
||||
await VisitAsync(formatting.Children);
|
||||
return formatting;
|
||||
}
|
||||
|
||||
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) =>
|
||||
inlineCodeBlock;
|
||||
protected virtual ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock) =>
|
||||
new(inlineCodeBlock);
|
||||
|
||||
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) =>
|
||||
multiLineCodeBlock;
|
||||
protected virtual ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(MultiLineCodeBlockNode multiLineCodeBlock) =>
|
||||
new(multiLineCodeBlock);
|
||||
|
||||
protected virtual MarkdownNode VisitLink(LinkNode link)
|
||||
protected virtual async ValueTask<MarkdownNode> VisitLinkAsync(LinkNode link)
|
||||
{
|
||||
Visit(link.Children);
|
||||
await VisitAsync(link.Children);
|
||||
return link;
|
||||
}
|
||||
|
||||
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) =>
|
||||
emoji;
|
||||
protected virtual ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji) =>
|
||||
new(emoji);
|
||||
|
||||
protected virtual MarkdownNode VisitMention(MentionNode mention) =>
|
||||
mention;
|
||||
protected virtual ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention) =>
|
||||
new(mention);
|
||||
|
||||
protected virtual MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) =>
|
||||
timestamp;
|
||||
protected virtual ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp) =>
|
||||
new(timestamp);
|
||||
|
||||
public MarkdownNode Visit(MarkdownNode node) => node switch
|
||||
public async ValueTask<MarkdownNode> VisitAsync(MarkdownNode node) => node switch
|
||||
{
|
||||
TextNode text => VisitText(text),
|
||||
FormattingNode formatting => VisitFormatting(formatting),
|
||||
InlineCodeBlockNode inlineCodeBlock => VisitInlineCodeBlock(inlineCodeBlock),
|
||||
MultiLineCodeBlockNode multiLineCodeBlock => VisitMultiLineCodeBlock(multiLineCodeBlock),
|
||||
LinkNode link => VisitLink(link),
|
||||
EmojiNode emoji => VisitEmoji(emoji),
|
||||
MentionNode mention => VisitMention(mention),
|
||||
UnixTimestampNode timestamp => VisitUnixTimestamp(timestamp),
|
||||
TextNode text => await VisitTextAsync(text),
|
||||
FormattingNode formatting => await VisitFormattingAsync(formatting),
|
||||
InlineCodeBlockNode inlineCodeBlock => await VisitInlineCodeBlockAsync(inlineCodeBlock),
|
||||
MultiLineCodeBlockNode multiLineCodeBlock => await VisitMultiLineCodeBlockAsync(multiLineCodeBlock),
|
||||
LinkNode link => await VisitLinkAsync(link),
|
||||
EmojiNode emoji => await VisitEmojiAsync(emoji),
|
||||
MentionNode mention => await VisitMentionAsync(mention),
|
||||
UnixTimestampNode timestamp => await VisitUnixTimestampAsync(timestamp),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(node))
|
||||
};
|
||||
|
||||
public void Visit(IEnumerable<MarkdownNode> nodes)
|
||||
public async ValueTask VisitAsync(IEnumerable<MarkdownNode> nodes)
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
Visit(node);
|
||||
await VisitAsync(node);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
@@ -25,6 +27,14 @@ public static class StringExtensions
|
||||
}
|
||||
}
|
||||
|
||||
public static string ToDashCase(this string str) =>
|
||||
Regex.Replace(str, @"(\p{Ll})(\p{Lu})", "$1-$2");
|
||||
|
||||
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) where T : struct, Enum =>
|
||||
Enum.TryParse<T>(str, ignoreCase, out var result)
|
||||
? result
|
||||
: null;
|
||||
|
||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||
builder.Length > 0
|
||||
? builder.Append(value)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Polly;
|
||||
@@ -13,14 +14,15 @@ public static class Http
|
||||
{
|
||||
public static HttpClient Client { get; } = new();
|
||||
|
||||
private static bool IsRetryableStatusCode(HttpStatusCode statusCode) => statusCode is
|
||||
HttpStatusCode.TooManyRequests or
|
||||
HttpStatusCode.RequestTimeout or
|
||||
HttpStatusCode.InternalServerError;
|
||||
private static bool IsRetryableStatusCode(HttpStatusCode statusCode) =>
|
||||
statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout ||
|
||||
// Treat all server-side errors as retryable.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/908
|
||||
(int)statusCode >= 500;
|
||||
|
||||
private static bool IsRetryableException(Exception exception) =>
|
||||
exception.GetSelfAndChildren().Any(ex =>
|
||||
ex is TimeoutException or SocketException ||
|
||||
ex is TimeoutException or SocketException or AuthenticationException ||
|
||||
ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK)
|
||||
);
|
||||
|
||||
|
||||
@@ -17,4 +17,8 @@ public static class PathEx
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
|
||||
public static bool IsDirectoryPath(string path) =>
|
||||
path.EndsWith(Path.DirectorySeparatorChar) ||
|
||||
path.EndsWith(Path.AltDirectorySeparatorChar);
|
||||
}
|
||||
@@ -14,14 +14,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Gress" Version="2.0.1" />
|
||||
<PackageReference Include="MaterialDesignColors" Version="2.0.6" />
|
||||
<PackageReference Include="MaterialDesignThemes" Version="4.5.0" />
|
||||
<PackageReference Include="MaterialDesignColors" Version="2.0.9" />
|
||||
<PackageReference Include="MaterialDesignThemes" Version="4.6.1" />
|
||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
||||
<PackageReference Include="Onova" Version="2.6.2" />
|
||||
<PackageReference Include="Stylet" Version="1.3.6" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||
<PackageReference Include="PropertyChanged.Fody" Version="3.4.1" PrivateAssets="all" />
|
||||
<PackageReference Include="PropertyChanged.Fody" Version="4.0.5" PrivateAssets="all" />
|
||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ public partial class SettingsService : SettingsManager
|
||||
|
||||
public int ParallelLimit { get; set; } = 1;
|
||||
|
||||
public bool ShouldReuseMedia { get; set; }
|
||||
public bool ShouldReuseAssets { get; set; }
|
||||
|
||||
public string? LastToken { get; set; }
|
||||
|
||||
@@ -26,7 +26,7 @@ public partial class SettingsService : SettingsManager
|
||||
|
||||
public string? LastMessageFilterValue { get; set; }
|
||||
|
||||
public bool LastShouldDownloadMedia { get; set; }
|
||||
public bool LastShouldDownloadAssets { get; set; }
|
||||
|
||||
public SettingsService()
|
||||
{
|
||||
|
||||
@@ -105,7 +105,11 @@ public class DashboardViewModel : PropertyChangedBase
|
||||
|
||||
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||
await foreach (var guild in discord.GetUserGuildsAsync())
|
||||
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
|
||||
{
|
||||
guildChannelMap[guild] = (await discord.GetGuildChannelsAsync(guild.Id))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
_discord = discord;
|
||||
GuildChannelMap = guildChannelMap;
|
||||
@@ -182,8 +186,8 @@ public class DashboardViewModel : PropertyChangedBase
|
||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||
dialog.PartitionLimit,
|
||||
dialog.MessageFilter,
|
||||
dialog.ShouldDownloadMedia,
|
||||
_settingsService.ShouldReuseMedia,
|
||||
dialog.ShouldDownloadAssets,
|
||||
_settingsService.ShouldReuseAssets,
|
||||
_settingsService.DateFormat
|
||||
);
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class ExportSetupViewModel : DialogScreen
|
||||
? MessageFilter.Parse(MessageFilterValue)
|
||||
: MessageFilter.Null;
|
||||
|
||||
public bool ShouldDownloadMedia { get; set; }
|
||||
public bool ShouldDownloadAssets { get; set; }
|
||||
|
||||
public bool IsAdvancedSectionDisplayed { get; set; }
|
||||
|
||||
@@ -72,7 +72,7 @@ public class ExportSetupViewModel : DialogScreen
|
||||
SelectedFormat = _settingsService.LastExportFormat;
|
||||
PartitionLimitValue = _settingsService.LastPartitionLimitValue;
|
||||
MessageFilterValue = _settingsService.LastMessageFilterValue;
|
||||
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
|
||||
ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets;
|
||||
|
||||
// Show the "advanced options" section by default if any
|
||||
// of the advanced options are set to non-default values.
|
||||
@@ -81,7 +81,7 @@ public class ExportSetupViewModel : DialogScreen
|
||||
Before != default ||
|
||||
!string.IsNullOrWhiteSpace(PartitionLimitValue) ||
|
||||
!string.IsNullOrWhiteSpace(MessageFilterValue) ||
|
||||
ShouldDownloadMedia != default;
|
||||
ShouldDownloadAssets != default;
|
||||
}
|
||||
|
||||
public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed;
|
||||
@@ -92,7 +92,7 @@ public class ExportSetupViewModel : DialogScreen
|
||||
_settingsService.LastExportFormat = SelectedFormat;
|
||||
_settingsService.LastPartitionLimitValue = PartitionLimitValue;
|
||||
_settingsService.LastMessageFilterValue = MessageFilterValue;
|
||||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||
_settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
|
||||
|
||||
// If single channel - prompt file path
|
||||
if (IsSingleChannel)
|
||||
|
||||
@@ -38,10 +38,10 @@ public class SettingsViewModel : DialogScreen
|
||||
set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
|
||||
}
|
||||
|
||||
public bool ShouldReuseMedia
|
||||
public bool ShouldReuseAssets
|
||||
{
|
||||
get => _settingsService.ShouldReuseMedia;
|
||||
set => _settingsService.ShouldReuseMedia = value;
|
||||
get => _settingsService.ShouldReuseAssets;
|
||||
set => _settingsService.ShouldReuseAssets = value;
|
||||
}
|
||||
|
||||
public SettingsViewModel(SettingsService settingsService) =>
|
||||
|
||||
@@ -135,22 +135,14 @@
|
||||
Grid.Row="2"
|
||||
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
||||
<Grid.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="FontWeight" Value="Light" />
|
||||
</Style>
|
||||
</Grid.Resources>
|
||||
<!-- Placeholder / usage instructions -->
|
||||
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
Margin="32,16"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="20">
|
||||
<Run FontSize="18" Text="Please provide authentication token to continue" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
|
||||
<!-- User token -->
|
||||
<InlineUIContainer>
|
||||
<materialDesign:PackIcon
|
||||
@@ -158,7 +150,7 @@
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account" />
|
||||
</InlineUIContainer>
|
||||
<Run FontSize="16" Text="To get token for your personal account:" />
|
||||
<Run FontSize="16" FontWeight="SemiBold" Text="To get token for your personal account:" />
|
||||
<LineBreak />
|
||||
<Run Text="* Automating user accounts is technically against TOS —" />
|
||||
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
|
||||
@@ -169,31 +161,33 @@
|
||||
</Hyperlink>
|
||||
<Run Text="and login" />
|
||||
<LineBreak />
|
||||
<Run Text="2. Press" />
|
||||
<Run Text="2. Open any server or direct message channel" />
|
||||
<LineBreak />
|
||||
<Run Text="3. Press" />
|
||||
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
|
||||
<Run Text="to show developer tools" />
|
||||
<LineBreak />
|
||||
<Run Text="3. Navigate to the" />
|
||||
<Run Text="4. Navigate to the" />
|
||||
<Run FontWeight="SemiBold" Text="Network" />
|
||||
<Run Text="tab" />
|
||||
<LineBreak />
|
||||
<Run Text="4. Press" />
|
||||
<Run Text="5. Press" />
|
||||
<Run FontWeight="SemiBold" Text="Ctrl+R" />
|
||||
<Run Text="to reload" />
|
||||
<LineBreak />
|
||||
<Run Text="5. Search for a request containing" />
|
||||
<Run Text="6. Search for a request containing" />
|
||||
<Run FontWeight="SemiBold" Text="messages?limit=50" />
|
||||
<Run Text="or similar" />
|
||||
<LineBreak />
|
||||
<Run Text="6. Select the" />
|
||||
<Run Text="7. Select the" />
|
||||
<Run FontWeight="SemiBold" Text="Headers" />
|
||||
<Run Text=" tab on the right" />
|
||||
<LineBreak />
|
||||
<Run Text="7. Scroll down to the" />
|
||||
<Run Text="8. Scroll down to the" />
|
||||
<Run FontWeight="SemiBold" Text="Request Headers" />
|
||||
<Run Text="section" />
|
||||
<LineBreak />
|
||||
<Run Text="8. Copy the value of the" />
|
||||
<Run Text="9. Copy the value of the" />
|
||||
<Run FontWeight="SemiBold" Text="authorization" />
|
||||
<Run Text="header" />
|
||||
<LineBreak />
|
||||
@@ -206,7 +200,7 @@
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Robot" />
|
||||
</InlineUIContainer>
|
||||
<Run FontSize="16" Text="To get token for a bot account:" />
|
||||
<Run FontSize="16" FontWeight="SemiBold" Text="To get token for a bot account:" />
|
||||
<LineBreak />
|
||||
<Run Text="1. Open Discord" />
|
||||
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
|
||||
@@ -224,10 +218,14 @@
|
||||
<Run Text="click" />
|
||||
<Run FontWeight="SemiBold" Text="Copy" />
|
||||
<LineBreak />
|
||||
<Run Text="* Your bot needs to have" />
|
||||
<Run FontWeight="SemiBold" Text="Message Content Intent" />
|
||||
<Run Text="enabled to read messages" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
|
||||
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
|
||||
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
|
||||
<Run Text="If you have questions or issues, please refer to the" />
|
||||
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
|
||||
</TextBlock>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
@@ -167,8 +167,8 @@
|
||||
Text="{Binding MessageFilterValue}"
|
||||
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." />
|
||||
|
||||
<!-- Download media -->
|
||||
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
|
||||
<!-- Download assets -->
|
||||
<Grid Margin="16,16" ToolTip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -177,12 +177,12 @@
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
VerticalAlignment="Center"
|
||||
Text="Download media" />
|
||||
Text="Download assets" />
|
||||
<ToggleButton
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
IsChecked="{Binding ShouldDownloadMedia}" />
|
||||
IsChecked="{Binding ShouldDownloadAssets}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -82,20 +82,20 @@
|
||||
IsChecked="{Binding IsTokenPersisted}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Reuse media -->
|
||||
<!-- Reuse assets -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
Background="Transparent"
|
||||
LastChildFill="False"
|
||||
ToolTip="Reuse already existing media content to skip redundant downloads">
|
||||
ToolTip="Reuse previously downloaded assets to avoid redundant requests">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
DockPanel.Dock="Left"
|
||||
Text="Reuse downloaded media" />
|
||||
Text="Reuse downloaded assets" />
|
||||
<ToggleButton
|
||||
VerticalAlignment="Center"
|
||||
DockPanel.Dock="Right"
|
||||
IsChecked="{Binding ShouldReuseMedia}" />
|
||||
IsChecked="{Binding ShouldReuseAssets}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Date format -->
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:s="https://github.com/canton7/Stylet"
|
||||
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
|
||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||
Width="600"
|
||||
Height="550"
|
||||
MinWidth="325"
|
||||
@@ -21,10 +22,11 @@
|
||||
</Window.TaskbarItemInfo>
|
||||
|
||||
<materialDesign:DialogHost
|
||||
x:Name="DialogHost"
|
||||
Loaded="{s:Action OnViewFullyLoaded}"
|
||||
SnackbarMessageQueue="{Binding Notifications}"
|
||||
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
||||
<Grid>
|
||||
<Grid IsEnabled="{Binding IsOpen, ElementName=DialogHost, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
||||
<ContentControl s:View.Model="{Binding Dashboard}" />
|
||||
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
|
||||
</Grid>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
[](https://tyrrrz.me/donate)
|
||||
[](https://twitter.com/tyrrrz/status/1495972128977571848)
|
||||
|
||||
> 🟢 **Project status**: active<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
|
||||
> 🟡 **Project status**: maintenance mode<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
|
||||
|
||||
**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, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
|
||||
@@ -61,10 +61,11 @@ The following table lists all available download options:
|
||||
<td><b>CLI</b></td>
|
||||
<td>
|
||||
<ul>
|
||||
<li>🟢 <b><a href="https://github.com/Tyrrrz/DiscordChatExporter/releases/latest">Stable release</a></b> (<code>DiscordChatExporter.CLI.zip</code>) ⚙️</li>
|
||||
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>) ⚙️</li>
|
||||
<li>🟢 <b><a href="https://github.com/Tyrrrz/DiscordChatExporter/releases/latest">Stable release</a></b> (<code>DiscordChatExporter.CLI.zip</code></li>
|
||||
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>)</li>
|
||||
<li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li>
|
||||
<li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>) 🦄</li>
|
||||
<li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>)</li>
|
||||
<li>📦 <a href="https://search.nixos.org/packages?query=discordchatexporter-cli">Nix</a> (<code>discordchatexporter-cli</code>)</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
@@ -78,12 +79,20 @@ The following table lists all available download options:
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
- ⚙️ - Requires .NET runtime to be installed manually:
|
||||
- [.NET v6.0 Runtime for **Windows x64**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-6.0.0-windows-x64-installer)
|
||||
- [.NET v6.0 Runtime for **Windows x86**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-6.0.0-windows-x86-installer)
|
||||
- [.NET v6.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-6.0.0-macos-x64-installer)
|
||||
- [.NET v6.0 Runtime for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find your distro)
|
||||
- 🦄 - Community-maintained resource
|
||||
> **Note**:
|
||||
> AUR and Nix packages linked above are maintained by the community.
|
||||
> If you have any issues with them, please contact the corresponding maintainers.
|
||||
|
||||
> **Warning**:
|
||||
> To run **DiscordChatExporter** on macOS and Linux, you need to make sure that **.NET Runtime v6** is installed.
|
||||
> You can download it here:
|
||||
>
|
||||
> - [.NET Runtime v6 for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-x64-installer)
|
||||
> - [.NET Runtime v6 for **macOS Arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-arm64-installer)
|
||||
> - [.NET Runtime v6 for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find the correct download for your distro)
|
||||
>
|
||||
> This should not be necessary if you install **DiscordChatExporter** using a package manager, as it should take care of the dependencies for you.
|
||||
> This is also not necessary if you are running **DiscordChatExporter** via Docker, because the image already contains the runtime.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -98,4 +107,9 @@ The following table lists all available download options:
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Related projects
|
||||
|
||||
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
|
||||
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
|
||||
Reference in New Issue
Block a user