Compare commits

..

23 Commits

Author SHA1 Message Date
Tyrrrz de773467b6 Update version 2021-07-19 01:58:23 +03:00
Tyrrrz 92766b580b [HTML] Show "Click to see attachment" on replies to attachment or embed messages without content
Closes #634
2021-07-19 01:12:30 +03:00
Tyrrrz abf7498667 Add support for timestamp markers
Closes #637
2021-07-18 16:04:39 +03:00
Tyrrrz ae42554621 Render voice channel mentions
Closes #636
2021-07-18 02:19:50 +03:00
Tyrrrz b58e67776c [HTML] Improve how the reference symbol looks
Closes #650
2021-07-18 01:09:52 +03:00
Tyrrrz 7fc6e0cfe0 Update readme 2021-07-18 00:19:25 +03:00
Tyrrrz 35264e8a12 Improve "Requested resource does not exist" exception message to give slightly more context 2021-07-18 00:15:06 +03:00
Tyrrrz 2ab6773c17 [GUI] Show error in a dialog when pull or export fails, instead of crashing the whole app 2021-07-18 00:02:28 +03:00
Tyrrrz 650c55bbd1 Refactor 2021-07-17 23:53:13 +03:00
Cliff J e1726683f8 Hide author icon when loading returns an error (#644)
Fixes #643
2021-07-13 14:56:36 -07:00
Yudi 70a1755c50 Update the in-app instructions for getting user token (#639)
Co-authored-by: virtuallyvlad <37453320+virtuallyvlad@users.noreply.github.com>
Co-authored-by: Alexey Golub <tyrrrrrr@gmail.com>
2021-07-13 07:26:26 -07:00
Tyrrrz 524b30b09b Update CliFx to fix NotSupportedException
Fixes #631
2021-07-09 22:53:12 +03:00
Lucas LaBuff d39fc212b6 Filter which messages are exported (#622) 2021-07-07 12:14:40 -07:00
Tyrrrz 95cd6cb50c Further cleanup issue forms 2021-06-24 20:46:42 +03:00
Tyrrrz 79a9ac0462 Refactor issue forms and add pull request template 2021-06-24 19:29:15 +03:00
Tyrrrz 5b5a53dbf4 [HTML] More template cleanup 2021-06-22 20:03:30 +03:00
Tyrrrz 2b0fa4213b [HTML] Cleanup templates slightly 2021-06-22 14:16:08 +03:00
Tyrrrz fd9d367bad [HTML] Cleanup formatting in stylesheet 2021-06-21 20:52:30 +03:00
Tyrrrz dbc3209160 [HTML] Merge all static stylesheets into a single dynamic stylesheet 2021-06-21 20:44:36 +03:00
Tyrrrz 3bcde0c534 [HTML] Move stylesheets into their own template 2021-06-21 19:57:15 +03:00
Tyrrrz 903882e35b Fix typos in changelog 2021-06-20 23:06:39 +03:00
Luke 3c6d9d74a4 Lazy load images (#613) 2021-06-20 05:50:14 -07:00
Tyrrrz 1e53f50ca3 [HTML] Simplify disabling jumbo in referenced message content 2021-06-19 23:09:07 +03:00
79 changed files with 1596 additions and 1237 deletions
+18 -9
View File
@@ -1,27 +1,31 @@
name: "\U0001F41E Bug report"
name: 🐞 Bug report
description: Report broken functionality.
labels: [bug]
body:
- type: markdown
attributes:
value: |
Important:
🧐 **Guidelines:**
- Please check existing issues (both opened and closed) to ensure that this bug hasn't been reported before.
- Refer to the [contribution guidelines](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Contributing.md) for info on how to file a good issue.
- Search through [existing issues](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) first to ensure that this bug has not been reported before.
- Write a descriptive title for your issue. Avoid generic or vague titles such as "Something's not working" or "A couple of problems".
- Keep your issue focused on one single problem. If you have multiple bug reports, 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 reporting a bug, please use [discussions](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
- type: input
attributes:
label: Version
description: "Which version(s) of DiscordChatExporter does this bug affect?"
description: Which version of DiscordChatExporter does this bug affect?
placeholder: ver X.Y.Z
validations:
required: true
- type: dropdown
attributes:
label: Flavor
description: "Which flavor(s) of DiscordChatExporter does this bug affect?"
description: Does this bug affect the GUI version, the CLI version, or both?
multiple: true
options:
- GUI (Graphical User Interface)
@@ -32,7 +36,7 @@ body:
- type: dropdown
attributes:
label: Export format
description: "Which export format(s) does this bug affect? If this bug is not related to any specific export format, leave this blank."
description: Does this bug affect specific export format(s)? If not applicable, leave this field blank.
multiple: true
options:
- HTML
@@ -43,13 +47,18 @@ body:
- type: textarea
attributes:
label: Details
description: "Clear and thorough explanation of the bug. If relevant, include screenshots or screen recordings."
description: Clear and thorough explanation of the bug.
placeholder: I was doing X expecting Y to happen, but Z happened instead.
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: "Minimum steps required to reproduce the bug."
description: Minimum steps required to reproduce the bug.
placeholder: |
- Step 1
- Step 2
- Step 3
validations:
required: true
+4 -4
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: "\U0001F4D6 Read the wiki"
- name: 📖 Wiki
url: https://github.com/Tyrrrz/DiscordChatExporter/wiki
about: Find answers to most common questions.
- name: "\U0001F5E8 Ask a question"
about: Find usage guides and frequently asked questions.
- name: 🗨 Discussions
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
about: Please ask and answer questions here.
about: Ask and answer questions.
+10 -7
View File
@@ -1,20 +1,23 @@
name: "\U00002728 Feature request"
name: Feature request
description: Request a new feature.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Important:
🧐 **Guidelines:**
- Please check existing issues (both opened and closed) to ensure that this feature hasn't been requested before.
- Refer to the [contribution guidelines](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Contributing.md) for info on how to file a good issue.
- 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: "Which flavor(s) of DiscordChatExporter does this feature request apply to? If this feature is not related to any specific flavor, leave this blank."
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)
@@ -23,7 +26,7 @@ body:
- type: dropdown
attributes:
label: Export format
description: "Which export format(s) does this feature request apply to? If this feature is not related to any specific export format, leave this blank."
description: Is this feature request related to specific export format(s)? If not applicable, leave this field blank.
multiple: true
options:
- HTML
@@ -34,6 +37,6 @@ body:
- type: textarea
attributes:
label: Details
description: "Clear and thorough explanation of the feature you have in mind. If relevant, include screenshots or screen recordings."
description: Clear and thorough explanation of the feature you have in mind.
validations:
required: true
+17
View File
@@ -0,0 +1,17 @@
<!--
**Important:**
Please make sure that there is an existing issue that describes the problem solved by your pull request. If there isn't one, consider creating it first.
An open issue offers a good place to iron out requirements, discuss possible solutions, and ask questions.
Remember to also:
- Keep your pull request focused and as small as possible. If you want to contribute multiple unrelated changes, please create separate pull requests for them.
- Follow the coding style and conventions already established by the project. When in doubt about which style to use, ask in the comments to your pull request.
- If you want to start a discussion regarding a specific change you've made, add a review comment to your own code. This can be used to highlight something important or to seek further input from others.
-->
<!-- Please specify the issue addressed by this pull request -->
Closes #ISSUE_NUMBER
+25 -11
View File
@@ -1,8 +1,22 @@
### v2.29 (19-Jul-2021)
- Added support for message filtering, which is accessible as an additional export parameter in the GUI and as the `--filter` option in the CLI. Message filters use a special syntax similar to Discord's search query syntax to allow specifying which messages should be included in the export. For example, you can limit the export to only include messages that contain image attachments (`has:image`) or messages from a certain user (`from:Tyrrrz`) or messages containing certain words (`hello world`), etc. You can also combine filters together (`hi mentions:Tyrrrz`, `from:Tyrrrz | from:96-LB`, `(mentions:Tyrrrz | from:Tyrrrz) has:image`) or negate them (`-has:link`). More thorough documentation coming soon. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
- Added support for rendering mentions of voice channels, in addition to text channels.
- Added support for rendering unix timestamp markers. Currently, style markers are ignored and timestamps are always rendered with the format configured in settings (or provided via `--date-format` option).
- Updated in-app instructions for obtaining a user token. (Thanks [@Yudi](https://github.com/Yudi) and [@virtuallyvlad](https://github.com/virtuallyvlad))
- [HTML] Improved visual appearance of the reference symbol (Г-shaped line in replies) to make it more closely resemble Discord's styling.
- [HTML] Added more specific fallback text used when rendering referenced messages that contain no text content but have attachments or embeds.
- [HTML] Fixed an issue where a broken embed author icon occupied visual space, ruining the embed layout. (Thanks [@Cliff J.](https://github.com/CliffJameston))
- [GUI] Improved handling for fatal errors that may occur when attempting to export a channel. These errors are now displayed in a popup, allowing the user to try again instead of having the application crash completely.
- [CLI] Fixed an issue where an attempt to export a channel resulted in `NotSupportedException` when the default encoding was set to `UTF8`.
- [CLI] Fixed an issue where providing an incorrectly formatted partition limit via `-p|--partition` option resulted in a non-descriptive and unhelpful error.
- Improved "Requested resource does not exist" error message by including the ID of the requested resource.
### v2.28 (19-Jun-2021)
- [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 representation. (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] 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.
@@ -96,7 +110,7 @@
### v2.21 (18-Jul-2020)
- 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 emojis. 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.
- 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.
@@ -135,7 +149,7 @@ Note that all existing and current HTML exports will likely not render accuratel
- Fixed an issue where an empty file was produced when exporting a channel with no messages (for specified period). With the new behavior, no file will be created and instead a message will be shown to the user informing of the failure.
- [HTML/TXT] Added message count to the bottom of the file. This number shows how many messages were exported as part of the current file. Prior to 2.16 this number was present along with the rest of the metadata at the top, then removed due to changes in the exporter, and now brought back at the end of the file instead.
- [HTML] Fixed an issue where some emojis were not properly rendered.
- [HTML] Fixed an issue where some emoji were not properly rendered.
- [GUI] Added a setting that lets you configure whether to remember the last used token. Previously it was the default behavior, but now you can disable it if you don't want the token persisted on your system.
### v2.16 (08-Dec-2019)
@@ -148,7 +162,7 @@ Note that all existing and current HTML exports will likely not render accuratel
- Fixed various issues that resulted in exceptions during export process.
- [HTML/TXT] Removed message count from the metadata. Due to the fact that the messages are exported as they are streamed, total number of messages is not known ahead of time.
- [HTML] Changed default color of the embed color pill to match the theme.
- [HTML] Changed emoji parser to be less greedy. As a result it should match fewer character sequences that look like emojis but really aren't, but on the other hand it might miss some actual emojis. This means that some standard emojis (i.e. not custom server emojis) may not look like in Discord. This is a compromise I'm willing to take because detecting emojis in text is really hard to get right and not worth it at all.
- [HTML] Changed emoji parser to be less greedy. As a result it should match fewer character sequences that look like emoji but really aren't, but on the other hand it might miss some actual emoji. This means that some standard emoji (i.e. not custom server emoji) may not look like in Discord. This is a compromise I'm willing to take because detecting emoji in text is really hard to get right and not worth it at all.
- [HTML] Some other minor styling adjustments.
- [CSV] Replaced semicolons (;) with (,) as separators.
@@ -189,8 +203,8 @@ Please note that CSV export is most likely not going to receive future updates a
### v2.12.1 (12-Apr-2019)
- [GUI] Fixed an issue where the app crashed when trying to export multiple channels.
- [HTML] Fixed an issue where some of the emojis were rendered via Twemoji while Discord renders them as plain text.
- [HTML] Fixed an issue where emojis weren't jumbo if there were more than one of them.
- [HTML] Fixed an issue where some of the emoji were rendered via Twemoji while Discord renders them as plain text.
- [HTML] Fixed an issue where emoji weren't jumbo if there were more than one of them.
### v2.12 (11-Apr-2019)
@@ -212,15 +226,15 @@ Please note that CSV export is most likely not going to receive future updates a
### v2.10.1 (06-Mar-2019)
- [HTML] Fixed an issue where multiple emojis on a single line would get rendered as one emoji.
- [HTML] Fixed an issue where multiple emoji on a single line would get rendered as one emoji.
- [HTML] Fixed an issue where an open square bracket character would incorrectly render as an emoji.
- [HTML] Added alt text to emoji images so that they will be rendered using default font when Twemoji images are not available.
### v2.10 (03-Mar-2019)
- [HTML] Reworked the entire markdown parsing engine which fixes numerous rendering inconsistencies (over 15 issues fixed).
- [HTML] Added support for animated emojis.
- [HTML] Standard emojis are now rendered using Twemoji, the same image set that Discord uses.
- [HTML] Added support for animated emoji.
- [HTML] Standard emoji are now rendered using Twemoji, the same image set that Discord uses.
- [HTML] Fixed an issue where video attachments would render incorrectly.
- [HTML] Minor fixes in styling.
- [GUI] Aggregated export progress is now shown in the taskbar.
@@ -280,7 +294,7 @@ Please note that CSV export is most likely not going to receive future updates a
- Added support for reactions (HTML only).
- Selected date range is now shown in the export (HTML and PlainText).
- Added placeholders for guild icons to display while the icons are loading.
- Added support for jumbo emojis (custom emojis only).
- Added support for jumbo emoji (custom emoji only).
- Added basic retry policy for 429 error responses.
- Added validation for from/to dates in the UI which prevents choosing invalid date ranges.
- Added an error message when trying to export a deleted channel.
@@ -304,7 +318,7 @@ Please note that CSV export is most likely not going to receive future updates a
- Improved message date filtering, it's now marginally faster.
- Fixed underscores not recognized as italics in markdown.
- Added support for custom emojis.
- Added support for custom emoji.
- Added support for user and role mentions.
- Added support for channel mentions.
- Fixed text in pre blocks not being wrapped correctly.
-56
View File
@@ -1,56 +0,0 @@
# Contributing
DiscordChatExporter accepts contributions in the form of issues and pull requests.
## Creating issues
If you have a feature suggestion or want to report a bug, you are welcome to create an issue.
Guidelines:
- Avoid creating an issue if a similar one already exists. Look through existing open and closed issues first.
- Keep your issue focused on one specific problem. If you have multiple suggestions or bug reports, please create separate issues for them.
- Provide a descriptive title for your issue. Don't use generic titles like "A couple suggestions" or "Not working".
- Provide more context in the body of the issue. If relevant, attach screenshots or screen recordings.
- Remain civil and respectful when participating in discussions.
## Creating pull requests
If you want to contribute code to the project, you can do so by creating a pull request.
Guidelines:
- Make sure that there is an existing issue that describes the problem solved by your pull request. If there isn't one, please create it first.
- Add a comment to the issue indicating that you want to submit a pull request for it. This can be a good opportunity to discuss possible solutions, finalize requirements, or ask questions.
- Keep your pull request focused and as small as possible. If you want to contribute multiple unrelated changes, please create separate pull requests for them.
- Follow the coding style and conventions already established by the project. When in doubt which style to use, ask in comments to your pull request.
- If you want to start a discussion regarding a specific change you made, add a review comment to your own code. This can be used to highlight something important or to seek input from others.
## Building the project locally
Prerequisites:
- [.NET 5.0 SDK](https://dotnet.microsoft.com/download/dotnet/5.0)
- [.NET 3.1 SDK](https://dotnet.microsoft.com/download/dotnet/3.1) (temporarily as the app still targets .NET 3.1, but requires .NET 5.0 to build)
- _(Optional)_ C#/.NET IDE, such as [JetBrains Rider](https://www.jetbrains.com/rider), [VS Code](https://code.visualstudio.com/docs/languages/csharp), or [Visual Studio](https://visualstudio.microsoft.com).
To build the entire solution run the following command in the root of the repository:
```sh
> dotnet build
```
This will generate runtime artifacts for each project:
```plaintext
./DiscordChatExporter.Gui/bin/[Debug|Release]/[runtime]/*
./DiscordChatExporter.Cli/bin/[Debug|Release]/[runtime]/*
```
You can also build and run a specific project directly.
To do that, navigate to its directory and use `dotnet run`:
```sh
> cd DiscordChatExporter.Gui
> dotnet run
```
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<Version>2.28</Version>
<Version>2.29</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<LangVersion>preview</LangVersion>
@@ -12,6 +12,7 @@ using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using Tyrrrz.Extensions;
@@ -32,8 +33,11 @@ namespace DiscordChatExporter.Cli.Commands.Base
[CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
public Snowflake? Before { get; init; }
[CommandOption("partition", 'p', Description = "Split output into partitions, each limited to this number of messages (e.g. 100) or file size (e.g. 10mb).")]
public PartitionLimit PartitionLimit { get; init; } = NullPartitionLimit.Instance;
[CommandOption("partition", 'p', Description = "Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb').")]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption("filter", Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image').")]
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
public int ParallelLimit { get; init; } = 1;
@@ -76,6 +80,7 @@ namespace DiscordChatExporter.Cli.Commands.Base
After,
Before,
PartitionLimit,
MessageFilter,
ShouldDownloadMedia,
ShouldReuseMedia,
DateFormat
@@ -128,8 +133,6 @@ namespace DiscordChatExporter.Cli.Commands.Base
{
throw new CommandException("Export failed.");
}
await console.Output.WriteLineAsync("Done.");
}
public override ValueTask ExecuteAsync(IConsole console)
@@ -31,6 +31,10 @@ namespace DiscordChatExporter.Cli.Commands
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id))
{
// Skip non-text channels
if (!channel.IsTextChannel)
continue;
channels.Add(channel);
}
}
@@ -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,9 +18,10 @@ namespace DiscordChatExporter.Cli.Commands
// Get channel metadata
await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id);
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
// Export
await ExportAsync(console, channels);
await ExportAsync(console, textChannels);
}
}
}
@@ -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;
@@ -20,9 +21,10 @@ namespace DiscordChatExporter.Cli.Commands
// Get channel metadata
await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(GuildId);
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
// Export
await ExportAsync(console, channels);
await ExportAsync(console, textChannels);
}
}
}
@@ -19,7 +19,13 @@ namespace DiscordChatExporter.Cli.Commands
{
var channels = await Discord.GetGuildChannelsAsync(GuildId);
foreach (var channel in channels.OrderBy(c => c.Category.Position).ThenBy(c => c.Name))
var textChannels = channels
.Where(c => c.IsTextChannel)
.OrderBy(c => c.Category.Position)
.ThenBy(c => c.Name)
.ToArray();
foreach (var channel in textChannels)
{
// Channel ID
await console.Output.WriteAsync(channel.Id.ToString());
@@ -16,7 +16,13 @@ namespace DiscordChatExporter.Cli.Commands
{
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id);
foreach (var channel in channels.OrderBy(c => c.Name))
var textChannels = channels
.Where(c => c.IsTextChannel)
.OrderBy(c => c.Category.Position)
.ThenBy(c => c.Name)
.ToArray();
foreach (var channel in textChannels)
{
// Channel ID
await console.Output.WriteAsync(channel.Id.ToString());
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
@@ -16,10 +16,12 @@ namespace DiscordChatExporter.Cli.Commands
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 3. Navigate to the Application tab");
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discord.com\" on the left");
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
console.Output.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
console.Output.WriteLine(" 4. Navigate to the Application tab");
console.Output.WriteLine(" 5. On the left, expand Local Storage and select https://discord.com");
console.Output.WriteLine(" 6. Type \"token\" into the Filter box");
console.Output.WriteLine(" 7. If the token key does not appear, press Ctrl+R to reload");
console.Output.WriteLine(" 8. Copy the value of the token key");
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
console.Output.WriteLine();
@@ -6,8 +6,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.0.4" />
<PackageReference Include="Spectre.Console" Version="0.40.0" />
<PackageReference Include="CliFx" Version="2.0.6" />
<PackageReference Include="Spectre.Console" Version="0.41.0" />
<PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
</ItemGroup>
@@ -17,18 +17,19 @@ namespace DiscordChatExporter.Core.Discord.Data
public string FileName { get; }
public string FileExtension => Path.GetExtension(FileName);
public int? Width { get; }
public int? Height { get; }
public bool IsImage => ImageFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsImage => ImageFileExtensions.Contains(FileExtension);
public bool IsVideo => VideoFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsVideo => VideoFileExtensions.Contains(FileExtension);
public bool IsAudio => AudioFileExtensions.Contains(Path.GetExtension(FileName));
public bool IsAudio => AudioFileExtensions.Contains(FileExtension);
public bool IsSpoiler =>
(IsImage || IsVideo || IsAudio) && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
public FileSize FileSize { get; }
@@ -21,6 +21,8 @@ namespace DiscordChatExporter.Core.Discord.Data
ChannelType.GuildNews or
ChannelType.GuildStore;
public bool IsVoiceChannel => !IsTextChannel;
public Snowflake GuildId { get; }
public ChannelCategory Category { get; }
@@ -51,7 +51,7 @@ namespace DiscordChatExporter.Core.Discord
{
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(),
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(),
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(),
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(url),
_ => DiscordChatExporterException.FailedHttpRequest(response)
};
}
@@ -142,10 +142,6 @@ namespace DiscordChatExporter.Core.Discord
var channel = Channel.Parse(channelJson, category, position);
// We are only interested in text channels
if (!channel.IsTextChannel)
continue;
position++;
yield return channel;
@@ -8,16 +8,12 @@
<PackageReference Include="JsonExtensions" Version="1.1.0" />
<PackageReference Include="MiniRazor.CodeGen" Version="2.1.2" />
<PackageReference Include="Polly" Version="7.2.2" />
<PackageReference Include="Superpower" Version="2.3.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Exporting\Writers\Html\Core.css" />
<EmbeddedResource Include="Exporting\Writers\Html\Dark.css" />
<EmbeddedResource Include="Exporting\Writers\Html\Light.css" />
<AdditionalFiles Include="Exporting\Writers\Html\PreambleTemplate.cshtml" IsRazorTemplate="true" />
<AdditionalFiles Include="Exporting\Writers\Html\PostambleTemplate.cshtml" IsRazorTemplate="true" />
<AdditionalFiles Include="Exporting\Writers\Html\MessageGroupTemplate.cshtml" IsRazorTemplate="true" />
<AdditionalFiles Include="Exporting\Writers\Html\*.cshtml" IsRazorTemplate="true" />
</ItemGroup>
</Project>
@@ -36,8 +36,8 @@ Failed to perform an HTTP request.
internal static DiscordChatExporterException Forbidden() =>
new("Access is forbidden.");
internal static DiscordChatExporterException NotFound() =>
new("Requested resource does not exist.");
internal static DiscordChatExporterException NotFound(string resourceId) =>
new($"Requested resource ({resourceId}) does not exist.");
internal static DiscordChatExporterException ChannelIsEmpty() =>
new("No messages found for the specified period.");
@@ -39,6 +39,10 @@ namespace DiscordChatExporter.Core.Exporting
var encounteredUsers = new HashSet<User>(IdBasedEqualityComparer.Instance);
await foreach (var message in _discord.GetMessagesAsync(request.Channel.Id, request.After, request.Before, progress))
{
// Skips any messages that fail to pass the supplied filter
if (!request.MessageFilter.Filter(message))
continue;
// Resolve members for referenced users
foreach (var referencedUser in message.MentionedUsers.Prepend(message.Author))
{
@@ -4,6 +4,7 @@ using System.Text;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils;
@@ -31,6 +32,8 @@ namespace DiscordChatExporter.Core.Exporting
public PartitionLimit PartitionLimit { get; }
public MessageFilter MessageFilter { get; }
public bool ShouldDownloadMedia { get; }
public bool ShouldReuseMedia { get; }
@@ -45,6 +48,7 @@ namespace DiscordChatExporter.Core.Exporting
Snowflake? after,
Snowflake? before,
PartitionLimit partitionLimit,
MessageFilter messageFilter,
bool shouldDownloadMedia,
bool shouldReuseMedia,
string dateFormat)
@@ -56,6 +60,7 @@ namespace DiscordChatExporter.Core.Exporting
After = after;
Before = before;
PartitionLimit = partitionLimit;
MessageFilter = messageFilter;
ShouldDownloadMedia = shouldDownloadMedia;
ShouldReuseMedia = shouldReuseMedia;
DateFormat = dateFormat;
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal enum BinaryExpressionKind
{
Or,
And
}
}
@@ -0,0 +1,26 @@
using System;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal class BinaryExpressionMessageFilter : MessageFilter
{
private readonly MessageFilter _first;
private readonly MessageFilter _second;
private readonly BinaryExpressionKind _kind;
public BinaryExpressionMessageFilter(MessageFilter first, MessageFilter second, BinaryExpressionKind kind)
{
_first = first;
_second = second;
_kind = kind;
}
public override bool Filter(Message message) => _kind switch
{
BinaryExpressionKind.Or => _first.Filter(message) || _second.Filter(message),
BinaryExpressionKind.And => _first.Filter(message) && _second.Filter(message),
_ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.")
};
}
}
@@ -0,0 +1,18 @@
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal class ContainsMessageFilter : MessageFilter
{
private readonly string _text;
public ContainsMessageFilter(string text) => _text = text;
public override bool Filter(Message message) => Regex.IsMatch(
message.Content,
"\\b" + _text + "\\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
);
}
}
@@ -0,0 +1,17 @@
using System;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal class FromMessageFilter : MessageFilter
{
private readonly string _value;
public FromMessageFilter(string value) => _value = value;
public override bool Filter(Message message) =>
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,25 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal class HasMessageFilter : MessageFilter
{
private readonly MessageContentMatchKind _kind;
public HasMessageFilter(MessageContentMatchKind kind) => _kind = kind;
public override bool Filter(Message message) => _kind switch
{
MessageContentMatchKind.Link => Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
MessageContentMatchKind.Embed => message.Embeds.Any(),
MessageContentMatchKind.File => message.Attachments.Any(),
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
};
}
}
@@ -0,0 +1,19 @@
using System;
using System.Linq;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal class MentionsMessageFilter : MessageFilter
{
private readonly string _value;
public MentionsMessageFilter(string value) => _value = value;
public override bool Filter(Message message) => message.MentionedUsers.Any(user =>
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
);
}
}
@@ -0,0 +1,12 @@
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal enum MessageContentMatchKind
{
Link,
Embed,
File,
Video,
Image,
Sound
}
}
@@ -0,0 +1,18 @@
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Filtering.Parsing;
using Superpower;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
public abstract partial class MessageFilter
{
public abstract bool Filter(Message message);
}
public partial class MessageFilter
{
public static MessageFilter Null { get; } = new NullMessageFilter();
public static MessageFilter Parse(string value) => FilterGrammar.Filter.Parse(value);
}
}
@@ -0,0 +1,13 @@
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal class NegatedMessageFilter : MessageFilter
{
private readonly MessageFilter _filter;
public NegatedMessageFilter(MessageFilter filter) => _filter = filter;
public override bool Filter(Message message) => !_filter.Filter(message);
}
}
@@ -0,0 +1,9 @@
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering
{
internal class NullMessageFilter : MessageFilter
{
public override bool Filter(Message message) => true;
}
}
@@ -0,0 +1,106 @@
using System.Linq;
using DiscordChatExporter.Core.Utils.Extensions;
using Superpower;
using Superpower.Parsers;
namespace DiscordChatExporter.Core.Exporting.Filtering.Parsing
{
internal static class FilterGrammar
{
// Choice(a, b) looks cleaner than a.Or(b)
private static TextParser<T> Choice<T>(params TextParser<T>[] parsers) =>
parsers.Aggregate((current, next) => current.Or(next));
private static readonly TextParser<char> EscapedCharacter =
Character.EqualTo('\\').IgnoreThen(Character.AnyChar);
private static readonly TextParser<string> QuotedString =
from open in Character.In('"', '\'')
from value in Choice(EscapedCharacter, Character.Except(open)).Many().Text()
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 =
Choice(EscapedCharacter, FreeCharacter).AtLeastOnce().Text();
private static readonly TextParser<string> String =
Choice(QuotedString, UnquotedString).Named("text string");
private static readonly TextParser<MessageFilter> ContainsFilter =
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> MentionsFilter = Span
.EqualToIgnoreCase("mentions:")
.IgnoreThen(String)
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
.Named("mentions:<value>");
private static readonly TextParser<MessageFilter> HasFilter = Span
.EqualToIgnoreCase("has:")
.IgnoreThen(Choice(
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound))
))
.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));
private static readonly TextParser<MessageFilter> GroupedFilter =
from open in Character.EqualTo('(')
from content in Parse.Ref(() => BinaryExpressionFilter).Token()
from close in Character.EqualTo(')')
select content;
private static readonly TextParser<MessageFilter> StandaloneFilter = Choice(
GroupedFilter,
FromFilter,
MentionsFilter,
HasFilter,
ContainsFilter
);
private static readonly TextParser<MessageFilter> UnaryExpressionFilter = Choice(
NegatedFilter,
StandaloneFilter
);
private static readonly TextParser<MessageFilter> BinaryExpressionFilter = Parse.Chain(
Choice(
// 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)
}
);
public static readonly TextParser<MessageFilter> Filter =
BinaryExpressionFilter.Token().AtEnd();
}
}
@@ -1,6 +1,6 @@
namespace DiscordChatExporter.Core.Exporting.Partitioning
{
public class FileSizePartitionLimit : PartitionLimit
internal class FileSizePartitionLimit : PartitionLimit
{
private readonly long _limit;
@@ -1,6 +1,6 @@
namespace DiscordChatExporter.Core.Exporting.Partitioning
{
public class MessageCountPartitionLimit : PartitionLimit
internal class MessageCountPartitionLimit : PartitionLimit
{
private readonly long _limit;
@@ -1,9 +1,7 @@
namespace DiscordChatExporter.Core.Exporting.Partitioning
{
public class NullPartitionLimit : PartitionLimit
internal class NullPartitionLimit : PartitionLimit
{
public static NullPartitionLimit Instance { get; } = new();
public override bool IsReached(long messagesWritten, long bytesWritten) => false;
}
}
@@ -11,6 +11,8 @@ namespace DiscordChatExporter.Core.Exporting.Partitioning
public partial class PartitionLimit
{
public static PartitionLimit Null { get; } = new NullPartitionLimit();
private static long? TryParseFileSizeBytes(string value, IFormatProvider? formatProvider = null)
{
var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase);
@@ -1,542 +0,0 @@
/* General */
@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
font-weight: 300;
}
@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
font-weight: 400;
}
@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
font-weight: 500;
}
@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
font-weight: 600;
}
@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
font-weight: 700;
}
body {
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 17px;
}
a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
object-fit: contain;
}
.markdown {
max-width: 100%;
line-height: 1.3;
overflow-wrap: break-word;
}
.preserve-whitespace {
white-space: pre-wrap;
}
.spoiler {
/* width: fit-content; */
display: inline-block;
/* This is more consistent across browsers, the old attribute worked well under Chrome but not FireFox. */
}
.spoiler--hidden {
cursor: pointer;
}
.spoiler-text {
border-radius: 3px;
}
.spoiler--hidden .spoiler-text {
color: rgba(0, 0, 0, 0);
}
.spoiler--hidden .spoiler-text::selection {
color: rgba(0, 0, 0, 0);
}
.spoiler-image {
position: relative;
overflow: hidden;
border-radius: 3px;
}
.spoiler--hidden .spoiler-image {
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
}
.spoiler--hidden .spoiler-image * {
filter: blur(44px);
}
.spoiler--hidden .spoiler-image:after {
content: "SPOILER";
color: #dcddde;
background-color: rgba(0, 0, 0, 0.6);
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
font-weight: 600;
padding: 100%;
border-radius: 20px;
letter-spacing: 0.05em;
font-size: 0.9em;
}
.spoiler--hidden:hover .spoiler-image:after {
color: #fff;
background-color: rgba(0, 0, 0, 0.9);
}
.quote {
margin: 0.1em 0;
padding-left: 0.6em;
border-left: 4px solid;
border-radius: 3px;
}
.pre {
font-family: "Consolas", "Courier New", Courier, monospace;
}
.pre--multiline {
margin-top: 0.25em;
padding: 0.5em;
border: 2px solid;
border-radius: 5px;
}
.pre--inline {
padding: 2px;
border-radius: 3px;
font-size: 0.85em;
}
.mention {
border-radius: 3px;
padding: 0 2px;
color: #7289da;
background: rgba(114, 137, 218, .1);
font-weight: 500;
}
.emoji {
width: 1.325em;
height: 1.325em;
margin: 0 0.06em;
vertical-align: -0.4em;
}
.emoji--small {
width: 1em;
height: 1em;
}
.emoji--large {
width: 2.8em;
height: 2.8em;
}
/* Preamble */
.preamble {
display: grid;
margin: 0 0.3em 0.6em 0.3em;
max-width: 100%;
grid-template-columns: auto 1fr;
}
.preamble__guild-icon-container {
grid-column: 1;
}
.preamble__guild-icon {
max-width: 88px;
max-height: 88px;
}
.preamble__entries-container {
grid-column: 2;
margin-left: 0.6em;
}
.preamble__entry {
font-size: 1.4em;
}
.preamble__entry--small {
font-size: 1em;
}
/* Chatlog */
.chatlog {
max-width: 100%;
}
.chatlog__message-group {
display: grid;
margin: 0 0.6em;
padding: 0.9em 0;
border-top: 1px solid;
grid-template-columns: auto 1fr;
}
.chatlog__reference-symbol {
grid-column: 1;
border-style: solid;
border-width: 2px 0 0 2px;
border-radius: 8px 0 0 0;
margin-left: 16px;
margin-top: 8px;
}
.chatlog__reference {
display: flex;
grid-column: 2;
margin-left: 1.2em;
margin-bottom: 0.25em;
font-size: 0.875em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-items: center;
}
.chatlog__reference-avatar {
border-radius: 50%;
height: 16px;
width: 16px;
margin-right: 0.25em;
}
.chatlog__reference-name {
margin-right: 0.25em;
font-weight: 600;
}
.chatlog__reference-link {
flex-grow: 1;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.chatlog__reference-content > * {
display: inline;
pointer-events: none;
}
.chatlog__reference-content > a:hover {
text-decoration: none;
}
.chatlog__reference-content > .emoji {
width: 1.325em;
height: 1.325em;
}
.chatlog__reference-edited-timestamp {
margin-left: 0.25em;
font-size: 0.8em;
}
.chatlog__author-avatar-container {
grid-column: 1;
width: 40px;
height: 40px;
}
.chatlog__author-avatar {
border-radius: 50%;
height: 40px;
width: 40px;
}
.chatlog__messages {
grid-column: 2;
margin-left: 1.2em;
min-width: 50%;
}
.chatlog__author-name {
font-weight: 500;
}
.chatlog__timestamp {
margin-left: 0.3em;
font-size: 0.75em;
}
.chatlog__message {
padding: 0.1em 0.3em;
margin: 0 -0.3em;
background-color: transparent;
transition: background-color 1s ease;
}
.chatlog__content {
font-size: 0.95em;
word-wrap: break-word;
}
.chatlog__edited-timestamp {
margin-left: 0.15em;
font-size: 0.8em;
}
.chatlog__attachment {
margin-top: 0.3em;
}
.chatlog__attachment-thumbnail {
vertical-align: top;
max-width: 45vw;
max-height: 500px;
border-radius: 3px;
}
.chatlog__attachment-container {
height: 40px;
width: 100%;
max-width: 520px;
padding: 10px;
border: 1px solid;
border-radius: 3px;
overflow: hidden;
}
.chatlog__attachment-icon {
float: left;
height: 100%;
margin-right: 10px;
}
.chatlog__attachment-icon > .a {
fill: #f4f5fb;
d: path("M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z");
}
.chatlog__attachment-icon > .b {
fill: #7789c4;
d: path("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");
}
.chatlog__attachment-icon > .c {
fill: #f4f5fb;
d: path("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");
}
.chatlog__attachment-icon > .d {
fill: #7789c4;
d: path("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");
}
.chatlog__attachment-filesize {
color: #72767d;
font-size: 12px;
}
.chatlog__attachment-filename {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chatlog__embed {
display: flex;
margin-top: 0.3em;
max-width: 520px;
}
.chatlog__embed-color-pill {
flex-shrink: 0;
width: 0.25em;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.chatlog__embed-content-container {
display: flex;
flex-direction: column;
padding: 0.5em 0.6em;
border: 1px solid;
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.chatlog__embed-content {
display: flex;
width: 100%;
}
.chatlog__embed-text {
flex: 1;
}
.chatlog__embed-author {
display: flex;
margin-bottom: 0.3em;
align-items: center;
}
.chatlog__embed-author-icon {
margin-right: 0.5em;
width: 20px;
height: 20px;
border-radius: 50%;
}
.chatlog__embed-author-name {
font-size: 0.875em;
font-weight: 600;
}
.chatlog__embed-title {
margin-bottom: 0.2em;
font-size: 0.875em;
font-weight: 600;
}
.chatlog__embed-description {
font-weight: 500;
font-size: 0.85em;
}
.chatlog__embed-fields {
display: flex;
flex-wrap: wrap;
}
.chatlog__embed-field {
flex: 0;
min-width: 100%;
max-width: 506px;
padding-top: 0.6em;
font-size: 0.875em;
}
.chatlog__embed-field--inline {
flex: 1;
flex-basis: auto;
min-width: 150px;
}
.chatlog__embed-field-name {
margin-bottom: 0.2em;
font-weight: 600;
}
.chatlog__embed-field-value {
font-weight: 500;
}
.chatlog__embed-thumbnail {
flex: 0;
margin-left: 1.2em;
max-width: 80px;
max-height: 80px;
border-radius: 3px;
}
.chatlog__embed-image-container {
margin-top: 0.6em;
}
.chatlog__embed-image {
max-width: 500px;
max-height: 400px;
border-radius: 3px;
}
.chatlog__embed-footer {
margin-top: 0.6em;
}
.chatlog__embed-footer-icon {
margin-right: 0.2em;
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
}
.chatlog__embed-footer-text {
vertical-align: middle;
font-size: 0.75em;
font-weight: 500;
}
.chatlog__embed-youtube-container {
margin-top: 0.6em;
}
.chatlog__embed-youtube {
border: 0;
border-radius: 3px;
}
.chatlog__reactions {
display: flex;
}
.chatlog__reaction {
display: flex;
align-items: center;
margin: 0.35em 0.1em 0.1em 0.1em;
padding: 0.2em 0.35em;
border-radius: 3px;
}
.chatlog__reaction-count {
min-width: 9px;
margin-left: 0.35em;
font-size: 0.875em;
}
.chatlog__bot-tag {
position: relative;
top: -.2em;
margin-left: 0.3em;
padding: 0.05em 0.3em;
border-radius: 3px;
vertical-align: middle;
line-height: 1.3;
background: #5865F2;
color: #ffffff;
font-size: 0.625em;
font-weight: 500;
}
/* Postamble */
.postamble {
margin: 1.4em 0.3em 0.6em 0.3em;
padding: 1em;
border-top: 1px solid;
}
@@ -1,147 +0,0 @@
/* General */
body {
background-color: #36393e;
color: #dcddde;
}
a {
color: #00aff4;
}
.spoiler-text {
background-color: rgba(255, 255, 255, 0.1);
}
.spoiler--hidden .spoiler-text {
background-color: #202225;
}
.spoiler--hidden:hover .spoiler-text {
background-color: rgba(32, 34, 37, 0.8);
}
.quote {
border-color: #4f545c;
}
.pre {
background-color: #2f3136 !important;
}
.pre--multiline {
border-color: #282b30 !important;
color: #b9bbbe !important;
}
/* Preamble */
.preamble__entry {
color: #ffffff;
}
/* Chatlog */
.chatlog__message-group {
border-color: rgba(255, 255, 255, 0.1);
}
.chatlog__reference-symbol {
border-color: #4f545c;
}
.chatlog__reference {
color: #b5b6b8;
}
.chatlog__reference-link {
color: #b5b6b8;
}
.chatlog__reference-link:hover {
color: #ffffff;
}
.chatlog__reference-edited-timestamp {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__author-name {
color: #ffffff;
}
.chatlog__timestamp {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__message--highlighted {
background-color: rgba(114, 137, 218, 0.2) !important;
}
.chatlog__message--pinned {
background-color: rgba(249, 168, 37, 0.05);
}
.chatlog__attachment-container {
background-color: #2f3136;
border-color: #292b2f;
}
.chatlog__edited-timestamp {
color: rgba(255, 255, 255, 0.2);
}
.chatlog__embed-color-pill--default {
background-color: #202225;
}
.chatlog__embed-content-container {
background-color: rgba(46, 48, 54, 0.3);
border-color: rgba(46, 48, 54, 0.6);
}
.chatlog__embed-author-name {
color: #ffffff;
}
.chatlog__embed-author-name-link {
color: #ffffff;
}
.chatlog__embed-title {
color: #ffffff;
}
.chatlog__embed-description {
color: #dcddde;
}
.chatlog__embed-field-name {
color: #ffffff;
}
.chatlog__embed-field-value {
color: #dcddde;
}
.chatlog__embed-footer {
color: #dcddde;
}
.chatlog__reaction {
background-color: rgba(255, 255, 255, 0.05);
}
.chatlog__reaction-count {
color: rgba(255, 255, 255, 0.3);
}
/* Postamble */
.postamble {
border-color: rgba(255, 255, 255, 0.1);
}
.postamble__entry {
color: #ffffff;
}
@@ -1,149 +0,0 @@
/* General */
body {
background-color: #ffffff;
color: #23262a;
font-weight: 500;
}
a {
color: #0068e0;
}
.spoiler-text {
background-color: rgba(0, 0, 0, 0.1);
}
.spoiler--hidden .spoiler-text {
background-color: #b9bbbe;
}
.spoiler--hidden:hover .spoiler-text {
background-color: rgba(185, 187, 190, 0.8);
}
.quote {
border-color: #c7ccd1;
}
.pre {
background-color: #f9f9f9 !important;
}
.pre--multiline {
border-color: #f3f3f3 !important;
color: #657b83 !important;
}
/* Preamble */
.preamble__entry {
color: #2f3136;
}
/* Chatlog */
.chatlog__message-group {
border-color: #eceeef;
}
.chatlog__reference-symbol {
border-color: #c7ccd1;
}
.chatlog__reference {
color: #5f5f60;
}
.chatlog__reference-link {
color: #5f5f60;
}
.chatlog__reference-link:hover {
color: #2f3136;
}
.chatlog__reference-edited-timestamp {
color: #747f8d;
}
.chatlog__author-name {
font-weight: 600;
color: #2f3136;
}
.chatlog__timestamp {
color: #747f8d;
}
.chatlog__message--highlighted {
background-color: rgba(114, 137, 218, 0.2) !important;
}
.chatlog__message--pinned {
background-color: rgba(249, 168, 37, 0.05);
}
.chatlog__attachment-container {
background-color: #f2f3f5;
border-color: #ebedef;
}
.chatlog__edited-timestamp {
color: #747f8d;
}
.chatlog__embed-color-pill--default {
background-color: rgba(227, 229, 232, 1);
}
.chatlog__embed-content-container {
background-color: rgba(249, 249, 249, 0.3);
border-color: rgba(204, 204, 204, 0.3);
}
.chatlog__embed-author-name {
color: #4f545c;
}
.chatlog__embed-author-name-link {
color: #4f545c;
}
.chatlog__embed-title {
color: #4f545c;
}
.chatlog__embed-description {
color: #2e3338;
}
.chatlog__embed-field-name {
color: #36393e;
}
.chatlog__embed-field-value {
color: #2e3338;
}
.chatlog__embed-footer {
color: #2e3338;
}
.chatlog__reaction {
background-color: rgba(79, 84, 92, 0.06);
}
.chatlog__reaction-count {
color: #747f8d;
}
/* Postamble */
.postamble {
border-color: #eceeef;
}
.postamble__entry {
color: #2f3136;
}
@@ -1,8 +1,10 @@
@using System
@using System.Linq
@using System.Threading.Tasks
@using DiscordChatExporter.Core.Exporting.Writers.Html;
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Core.Exporting.Writers.Html.MessageGroupTemplateContext>
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@{
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
@@ -45,20 +47,24 @@
}
<div class="chatlog__message-group">
@{/* Referenced message */}
@if (Model.MessageGroup.Reference is not null)
{
<div class="chatlog__reference-symbol">
</div>
<div class="chatlog__reference-symbol"></div>
<div class="chatlog__reference">
@if (Model.MessageGroup.ReferencedMessage is not null)
{
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar">
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<span class="chatlog__reference-name" title="@Model.MessageGroup.ReferencedMessage.Author.FullName" style="@referencedUserColorStyle">@referencedUserNick</span>
<div class="chatlog__reference-link" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
<span class="chatlog__reference-content">
<div class="chatlog__reference-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content))
{
@Raw(FormatMarkdown(Model.MessageGroup.ReferencedMessage.Content))
@Raw(FormatEmbedMarkdown(Model.MessageGroup.ReferencedMessage.Content))
}
else if (Model.MessageGroup.ReferencedMessage.Attachments.Any() || Model.MessageGroup.ReferencedMessage.Embeds.Any())
{
<em>Click to see attachment</em> <span>🖼️</span>
}
else
{
@@ -80,30 +86,37 @@
}
</div>
}
@{/* Avatar */}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar">
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar" loading="lazy">
</div>
<div class="chatlog__messages">
@{/* Author name */}
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
@{/* Bot tag */}
@if (Model.MessageGroup.Author.IsBot)
{
<span class="chatlog__bot-tag">BOT</span>
}
@{/* Message timestamp */}
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
@{/* Messages in a group */}
@foreach (var message in Model.MessageGroup.Messages)
{
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id" title="Message sent: @FormatDate(message.Timestamp)">
<div class="chatlog__message @(message.IsPinned ? "chatlog__message--pinned" : null)" data-message-id="@message.Id" id="message-@message.Id" title="Message sent: @FormatDate(message.Timestamp)">
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
{
<div class="chatlog__content">
<div class="markdown">
@{/* Message content */}
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
@{/* Edit timestamp */}
@if (message.EditedTimestamp is not null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
@@ -112,62 +125,64 @@
</div>
}
@{/* Attachments */}
@foreach (var attachment in message.Attachments)
{
<div class="chatlog__attachment">
<div class="@(attachment.IsSpoiler ? "spoiler spoiler--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
<div class="@(attachment.IsSpoiler ? "spoiler-image" : "")">
@if (attachment.IsImage)
{
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
@{/* 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="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="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="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
</audio>
}
else
{
<div class="chatlog__attachment-generic">
<svg class="chatlog__attachment-generic-icon">
<use href="#icon-attachment" />
</svg>
<div class="chatlog__attachment-generic-name">
<a href="@await ResolveUrlAsync(attachment.Url)">
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")">
@attachment.FileName
</a>
}
else if (attachment.IsVideo)
{
<video controls class="chatlog__attachment-thumbnail">
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
</video>
}
else if (attachment.IsAudio)
{
<audio controls class="chatlog__attachment-thumbnail">
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
</audio>
}
else
{
<div class="chatlog__attachment-container">
<svg class="chatlog__attachment-icon" viewBox="0 0 720 960">
<path class="a"/>
<path class="b"/>
<path class="c"/>
<path class="d"/>
</svg>
<div class="chatlog__attachment-filename">
<a href="@await ResolveUrlAsync(attachment.Url)">
@attachment.FileName
</a>
</div>
<div class="chatlog__attachment-filesize">
@attachment.FileSize
</div>
</div>
}
</div>
<div class="chatlog__attachment-generic-size">
@attachment.FileSize
</div>
</div>
</div>
}
</div>
}
@{/* Embeds */}
@foreach (var embed in message.Embeds)
{
var youTubeVideo = embed.TryGetYouTubeVideo();
<div class="chatlog__embed">
@{/* Color pill */}
@if (embed.Color is not null)
{
var embedColorStyle = $"background-color: rgba({embed.Color?.R},{embed.Color?.G},{embed.Color?.B},{embed.Color?.A})";
<div class="chatlog__embed-color-pill" style="@embedColorStyle"></div>
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
}
else
{
@@ -177,12 +192,13 @@
<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">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon">
<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))
@@ -201,6 +217,7 @@
</div>
}
@{/* Embed title */}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@@ -217,6 +234,7 @@
</div>
}
@{/* Embed description (with special casing for YouTube videos) */}
@if (youTubeVideo is not null)
{
<div class="chatlog__embed-youtube-container">
@@ -230,14 +248,13 @@
</div>
}
@{/* Embed fields */}
@if (embed.Fields.Any())
{
<div class="chatlog__embed-fields">
@foreach (var field in embed.Fields)
{
var isInlineStyle = field.IsInline ? "chatlog__embed-field--inline" : null;
<div class="chatlog__embed-field @isInlineStyle">
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
@@ -257,34 +274,39 @@
}
</div>
@{/* Embed content (not shown for YouTube videos) */}
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url) && youTubeVideo is null)
{
<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">
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
</a>
</div>
}
</div>
@{/* Embed image */}
@if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url))
{
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image" loading="lazy">
</a>
</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">
<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
@@ -295,6 +317,7 @@
@(" • ")
}
@{/* Embed timestamp */}
@if (embed.Timestamp is not null)
{
@FormatDate(embed.Timestamp.Value)
@@ -306,13 +329,14 @@
</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="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
<span class="chatlog__reaction-count">@reaction.Count</span>
</div>
}
@@ -1,6 +1,9 @@
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Core.Exporting.Writers.Html.PostambleTemplateContext>
@using DiscordChatExporter.Core.Exporting.Writers.Html;
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<PostambleTemplateContext>
@{/* Close elements opened by preamble */}
</div>
<div class="postamble">
@@ -1,15 +1,19 @@
@using System
@using System.Threading.Tasks
@using Tyrrrz.Extensions
@using DiscordChatExporter.Core.Exporting.Writers.Html;
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Core.Exporting.Writers.Html.PreambleTemplateContext>
@inherits MiniRazor.TemplateBase<PreambleTemplateContext>
@{
string Themed(string darkVariant, string lightVariant) =>
string.Equals(Model.ThemeName, "Dark", StringComparison.OrdinalIgnoreCase)
? darkVariant
: lightVariant;
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string GetStyleSheet(string name) => Model.GetType().Assembly.GetManifestResourceString($"DiscordChatExporter.Core.Exporting.Writers.Html.{name}.css");
}
<!DOCTYPE html>
@@ -20,13 +24,595 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
@{/* Styling */}
<style>
@Raw(GetStyleSheet("Core"))
</style>
<style>
@Raw(GetStyleSheet(Model.ThemeName))
@@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
font-weight: 300;
}
@@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
font-weight: 400;
}
@@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
font-weight: 500;
}
@@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
font-weight: 600;
}
@@font-face {
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
font-weight: 700;
}
body {
background-color: @Themed("#36393e", "#ffffff");
color: @Themed("#dcddde", "#23262a");
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 17px;
font-weight: @Themed("400", "500");
}
a {
color: @Themed("#00aff4", "#0068e0");
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
object-fit: contain;
}
.markdown {
max-width: 100%;
line-height: 1.3;
overflow-wrap: break-word;
}
.preserve-whitespace {
white-space: pre-wrap;
}
.spoiler-text {
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
border-radius: 3px;
}
.spoiler-text--hidden {
cursor: pointer;
background-color: @Themed("#202225", "#b9bbbe");
color: rgba(0, 0, 0, 0);
}
.spoiler-text--hidden:hover {
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
}
.spoiler-text--hidden::selection {
color: rgba(0, 0, 0, 0);
}
.quote {
margin: 0.1em 0;
padding-left: 0.6em;
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 3px;
}
.pre {
background-color: @Themed("#2f3136", "#f9f9f9");
font-family: "Consolas", "Courier New", Courier, monospace;
}
.pre--multiline {
margin-top: 0.25em;
padding: 0.5em;
border: 2px solid @Themed("#282b30", "#f3f3f3");
border-radius: 5px;
color: @Themed("#b9bbbe", "#657b83");
}
@{/* Override Highlight.js styles with a higher specificity selector */}
.pre--multiline.hljs {
background-color: @Themed("#2f3136", "#f9f9f9");
color: @Themed("#b9bbbe", "#657b83");
}
.pre--inline {
padding: 2px;
border-radius: 3px;
font-size: 0.85em;
}
.mention {
border-radius: 3px;
padding: 0 2px;
color: #7289da;
background-color: rgba(114, 137, 218, .1);
font-weight: 500;
}
.timestamp {
border-radius: 3px;
padding: 0 2px;
background-color: @Themed("rgba(255, 255, 255, 0.06)", "rgba(6, 6, 7, 0.08)");
}
.emoji {
width: 1.325em;
height: 1.325em;
margin: 0 0.06em;
vertical-align: -0.4em;
}
.emoji--small {
width: 1em;
height: 1em;
}
.emoji--large {
width: 2.8em;
height: 2.8em;
}
.preamble {
display: grid;
margin: 0 0.3em 0.6em 0.3em;
max-width: 100%;
grid-template-columns: auto 1fr;
}
.preamble__guild-icon-container {
grid-column: 1;
}
.preamble__guild-icon {
max-width: 88px;
max-height: 88px;
}
.preamble__entries-container {
grid-column: 2;
margin-left: 0.6em;
}
.preamble__entry {
font-size: 1.4em;
color: @Themed("#ffffff", "#2f3136");
}
.preamble__entry--small {
font-size: 1em;
}
.chatlog {
max-width: 100%;
}
.chatlog__message-group {
display: grid;
margin: 0 0.6em;
padding: 0.9em 0;
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
grid-template-columns: auto 1fr;
}
.chatlog__reference-symbol {
grid-column: 1;
margin: 8px 4px 4px 18px;
border-left: 2px solid @Themed("#4f545c", "#c7ccd1");
border-top: 2px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 8px 0 0 0;
}
.chatlog__reference {
display: flex;
grid-column: 2;
margin-bottom: 0.25em;
font-size: 0.875em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-items: center;
color: @Themed("#b5b6b8", "#5f5f60");
}
.chatlog__reference-avatar {
border-radius: 50%;
width: 16px;
height: 16px;
margin-right: 0.25em;
}
.chatlog__reference-name {
margin-right: 0.25em;
font-weight: 600;
}
.chatlog__reference-content {
overflow: hidden;
text-overflow: ellipsis;
}
.chatlog__reference-content a:hover {
text-decoration: none;
}
.chatlog__reference-link {
cursor: pointer;
color: @Themed("#b5b6b8", "#5f5f60");
}
.chatlog__reference-link * {
display: inline;
pointer-events: none;
}
.chatlog__reference-link:hover {
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__reference-link:hover *:not(.spoiler-text) {
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__reference-edited-timestamp {
margin-left: 0.25em;
font-size: 0.8em;
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__author-avatar-container {
grid-column: 1;
width: 40px;
height: 40px;
margin-right: 16px;
}
.chatlog__author-avatar {
border-radius: 50%;
width: 40px;
height: 40px;
}
.chatlog__messages {
grid-column: 2;
min-width: 50%;
}
.chatlog__author-name {
font-weight: @Themed("500", "600");
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__timestamp {
margin-left: 0.3em;
font-size: 0.75em;
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__message {
padding: 0.1em 0.3em;
margin: 0 -0.3em;
background-color: transparent;
transition: background-color 1s ease;
}
.chatlog__message--highlighted {
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
}
.chatlog__message--pinned {
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
}
.chatlog__content {
font-size: 0.95em;
word-wrap: break-word;
}
.chatlog__edited-timestamp {
margin-left: 0.15em;
font-size: 0.8em;
}
.chatlog__attachment {
display: inline-block;
position: relative;
margin-top: 0.3em;
border-radius: 3px;
overflow: hidden;
}
.chatlog__attachment--hidden {
cursor: pointer;
box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.1);
}
.chatlog__attachment--hidden * {
pointer-events: none;
}
.chatlog__attachment-spoiler-caption {
display: none;
z-index: 999;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.4em 0.8em;
background-color: rgba(0, 0, 0, 0.9);
border-radius: 20px;
color: #dcddde;
font-size: 0.9em;
font-weight: 600;
letter-spacing: 0.05em;
}
.chatlog__attachment--hidden .chatlog__attachment-spoiler-caption {
display: block;
}
.chatlog__attachment--hidden:hover .chatlog__attachment-spoiler-caption {
color: #fff;
}
.chatlog__attachment-media {
vertical-align: top;
max-width: 45vw;
max-height: 500px;
border-radius: 3px;
}
.chatlog__attachment--hidden .chatlog__attachment-media {
filter: blur(44px);
}
.chatlog__attachment-generic {
width: 100%;
max-width: 520px;
height: 40px;
padding: 10px;
background-color: @Themed("#2f3136", "#f2f3f5");
border: 1px solid @Themed("#292b2f", "#ebedef");
border-radius: 3px;
overflow: hidden;
}
.chatlog__attachment--hidden .chatlog__attachment-generic {
filter: blur(44px);
}
.chatlog__attachment-generic-icon {
float: left;
width: 30px;
height: 100%;
margin-right: 10px;
}
.chatlog__attachment-generic-size {
color: #72767d;
font-size: 12px;
}
.chatlog__attachment-generic-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chatlog__edited-timestamp {
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__embed {
display: flex;
margin-top: 0.3em;
max-width: 520px;
}
.chatlog__embed-color-pill {
flex-shrink: 0;
width: 0.25em;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
.chatlog__embed-color-pill--default {
background-color: @Themed("#202225", "rgba(227, 229, 232, 1)");
}
.chatlog__embed-content-container {
display: flex;
flex-direction: column;
padding: 0.5em 0.6em;
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)");
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
}
.chatlog__embed-content {
display: flex;
width: 100%;
}
.chatlog__embed-text {
flex: 1;
}
.chatlog__embed-author {
display: flex;
margin-bottom: 0.3em;
align-items: center;
}
.chatlog__embed-author-icon {
margin-right: 0.5em;
width: 20px;
height: 20px;
border-radius: 50%;
}
.chatlog__embed-author-name {
font-size: 0.875em;
font-weight: 600;
color: @Themed("#ffffff", "#4f545c")
}
.chatlog__embed-author-name-link {
color: @Themed("#ffffff", "#4f545c");
}
.chatlog__embed-title {
margin-bottom: 0.2em;
font-size: 0.875em;
font-weight: 600;
color: @Themed("#ffffff", "#4f545c");
}
.chatlog__embed-description {
font-weight: 500;
font-size: 0.85em;
color: @Themed("#dcddde", "#2e3338");
}
.chatlog__embed-fields {
display: flex;
flex-wrap: wrap;
}
.chatlog__embed-field {
flex: 0;
min-width: 100%;
max-width: 506px;
padding-top: 0.6em;
font-size: 0.875em;
}
.chatlog__embed-field--inline {
flex: 1;
flex-basis: auto;
min-width: 150px;
}
.chatlog__embed-field-name {
margin-bottom: 0.2em;
font-weight: 600;
color: @Themed("#ffffff", "#36393e");
}
.chatlog__embed-field-value {
font-weight: 500;
color: @Themed("#dcddde", "#2e3338");
}
.chatlog__embed-thumbnail {
flex: 0;
margin-left: 1.2em;
max-width: 80px;
max-height: 80px;
border-radius: 3px;
}
.chatlog__embed-image-container {
margin-top: 0.6em;
}
.chatlog__embed-image {
max-width: 500px;
max-height: 400px;
border-radius: 3px;
}
.chatlog__embed-footer {
margin-top: 0.6em;
color: @Themed("#dcddde", "#2e3338");
}
.chatlog__embed-footer-icon {
margin-right: 0.2em;
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
}
.chatlog__embed-footer-text {
vertical-align: middle;
font-size: 0.75em;
font-weight: 500;
}
.chatlog__embed-youtube-container {
margin-top: 0.6em;
}
.chatlog__embed-youtube {
border: 0;
border-radius: 3px;
}
.chatlog__reactions {
display: flex;
}
.chatlog__reaction {
display: flex;
align-items: center;
margin: 0.35em 0.1em 0.1em 0.1em;
padding: 0.2em 0.35em;
background-color: @Themed("rgba(255, 255, 255, 0.05)", "rgba(79, 84, 92, 0.06)");
border-radius: 3px;
}
.chatlog__reaction-count {
min-width: 9px;
margin-left: 0.35em;
font-size: 0.875em;
color: @Themed("rgba(255, 255, 255, 0.3)", "#747f8d");
}
.chatlog__bot-tag {
position: relative;
top: -.2em;
margin-left: 0.3em;
padding: 0.05em 0.3em;
border-radius: 3px;
vertical-align: middle;
line-height: 1.3;
background-color: #5865F2;
color: #ffffff;
font-size: 0.625em;
font-weight: 500;
}
.postamble {
margin: 1.4em 0.3em 0.6em 0.3em;
padding: 1em;
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
}
.postamble__entry {
color: @Themed("#ffffff", "#2f3136");
}
</style>
@{/* Syntax highlighting */}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
@@ -35,6 +621,7 @@
});
</script>
@{/* Scripts */}
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
@@ -56,18 +643,32 @@
}
function showSpoiler(event, element) {
if (element && element.classList.contains('spoiler--hidden')) {
if (element && element.classList.contains('spoiler-text--hidden')) {
event.preventDefault();
element.classList.remove('spoiler--hidden');
element.classList.remove('spoiler-text--hidden');
}
if (element && element.classList.contains('chatlog__attachment--hidden')) {
event.preventDefault();
element.classList.remove('chatlog__attachment--hidden');
}
}
</script>
@{/* Icons */}
<svg style="display: none">
<symbol id="icon-attachment" 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>
</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">
<img class="preamble__guild-icon" src="@await ResolveUrlAsync(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>
@@ -98,4 +699,5 @@
</div>
</div>
@{/* Preamble cuts off at this point */}
<div class="chatlog">
@@ -6,7 +6,7 @@ using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Ast;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
@@ -39,7 +39,7 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
TextFormatting.Underline => ("<u>", "</u>"),
TextFormatting.Strikethrough => ("<s>", "</s>"),
TextFormatting.Spoiler => (
"<span class=\"spoiler spoiler--hidden\" onclick=\"showSpoiler(event, this)\"><span class=\"spoiler-text\">", "</span></span>"),
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
TextFormatting.Quote => ("<div class=\"quote\">", "</div>"),
_ => throw new ArgumentOutOfRangeException(nameof(formatted.Formatting))
};
@@ -75,67 +75,6 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
}
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Type == MentionType.Meta)
{
_buffer
.Append("<span class=\"mention\">")
.Append("@").Append(HtmlEncode(mention.Id))
.Append("</span>");
}
else if (mention.Type == MentionType.User)
{
var member = mentionId?.Pipe(_context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown";
_buffer
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
.Append("@").Append(HtmlEncode(nick))
.Append("</span>");
}
else if (mention.Type == MentionType.Channel)
{
var channel = mentionId?.Pipe(_context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel";
_buffer
.Append("<span class=\"mention\">")
.Append("#").Append(HtmlEncode(name))
.Append("</span>");
}
else if (mention.Type == MentionType.Role)
{
var role = mentionId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
var color = role?.Color;
var style = color is not null
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
: "";
_buffer
.Append($"<span class=\"mention\" style=\"{style}\">")
.Append("@").Append(HtmlEncode(name))
.Append("</span>");
}
return base.VisitMention(mention);
}
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
{
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "emoji--large" : "";
_buffer
.Append($"<img class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
return base.VisitEmoji(emoji);
}
protected override MarkdownNode VisitLink(LinkNode link)
{
// Extract message ID if the link points to a Discord message
@@ -158,6 +97,78 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
return base.VisitLink(link);
}
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
{
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "emoji--large" : "";
_buffer
.Append($"<img loading=\"lazy\" class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
return base.VisitEmoji(emoji);
}
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Kind == MentionKind.Meta)
{
_buffer
.Append("<span class=\"mention\">")
.Append("@").Append(HtmlEncode(mention.Id))
.Append("</span>");
}
else if (mention.Kind == MentionKind.User)
{
var member = mentionId?.Pipe(_context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown";
_buffer
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
.Append("@").Append(HtmlEncode(nick))
.Append("</span>");
}
else if (mention.Kind == MentionKind.Channel)
{
var channel = mentionId?.Pipe(_context.TryGetChannel);
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel";
_buffer
.Append("<span class=\"mention\">")
.Append(symbol).Append(HtmlEncode(name))
.Append("</span>");
}
else if (mention.Kind == MentionKind.Role)
{
var role = mentionId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
var color = role?.Color;
var style = color is not null
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
: "";
_buffer
.Append($"<span class=\"mention\" style=\"{style}\">")
.Append("@").Append(HtmlEncode(name))
.Append("</span>");
}
return base.VisitMention(mention);
}
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
{
_buffer
.Append("<span class=\"timestamp\">")
.Append(HtmlEncode(_context.FormatDate(timestamp.Value)))
.Append("</span>");
return base.VisitUnixTimestamp(timestamp);
}
}
internal partial class HtmlMarkdownVisitor
@@ -1,7 +1,7 @@
using System.Text;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Ast;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
@@ -23,28 +23,43 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
return base.VisitText(text);
}
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
{
_buffer.Append(
emoji.IsCustomEmoji
? $":{emoji.Name}:"
: emoji.Name
);
return base.VisitEmoji(emoji);
}
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Type == MentionType.Meta)
if (mention.Kind == MentionKind.Meta)
{
_buffer.Append($"@{mention.Id}");
}
else if (mention.Type == MentionType.User)
else if (mention.Kind == MentionKind.User)
{
var member = mentionId?.Pipe(_context.TryGetMember);
var name = member?.User.Name ?? "Unknown";
_buffer.Append($"@{name}");
}
else if (mention.Type == MentionType.Channel)
else if (mention.Kind == MentionKind.Channel)
{
var channel = mentionId?.Pipe(_context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{name}");
// Voice channel marker
if (channel?.IsVoiceChannel == true)
_buffer.Append(" [voice]");
}
else if (mention.Type == MentionType.Role)
else if (mention.Kind == MentionKind.Role)
{
var role = mentionId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
@@ -55,15 +70,13 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
return base.VisitMention(mention);
}
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
{
_buffer.Append(
emoji.IsCustomEmoji
? $":{emoji.Name}:"
: emoji.Name
_context.FormatDate(timestamp.Value)
);
return base.VisitEmoji(emoji);
return base.VisitUnixTimestamp(timestamp);
}
}
@@ -1,17 +0,0 @@
namespace DiscordChatExporter.Core.Markdown.Ast
{
internal class MentionNode : MarkdownNode
{
public string Id { get; }
public MentionType Type { get; }
public MentionNode(string id, MentionType type)
{
Id = id;
Type = type;
}
public override string ToString() => $"<{Type} mention> {Id}";
}
}
@@ -1,10 +0,0 @@
namespace DiscordChatExporter.Core.Markdown.Ast
{
internal enum MentionType
{
Meta,
User,
Channel,
Role
}
}
@@ -1,6 +1,6 @@
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal class EmojiNode : MarkdownNode
{
@@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal class FormattedNode : MarkdownNode
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal class InlineCodeBlockNode : MarkdownNode
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal class LinkNode : MarkdownNode
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal abstract class MarkdownNode
{
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Core.Markdown
{
internal enum MentionKind
{
Meta,
User,
Channel,
Role
}
}
@@ -0,0 +1,17 @@
namespace DiscordChatExporter.Core.Markdown
{
internal class MentionNode : MarkdownNode
{
public string Id { get; }
public MentionKind Kind { get; }
public MentionNode(string id, MentionKind kind)
{
Id = id;
Kind = kind;
}
public override string ToString() => $"<{Kind} mention> {Id}";
}
}
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal class MultiLineCodeBlockNode : MarkdownNode
{
@@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Matching
namespace DiscordChatExporter.Core.Markdown.Parsing
{
internal class AggregateMatcher<T> : IMatcher<T>
{
@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Matching
namespace DiscordChatExporter.Core.Markdown.Parsing
{
internal interface IMatcher<T>
{
@@ -1,11 +1,11 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Markdown.Ast;
using DiscordChatExporter.Core.Markdown.Matching;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown
namespace DiscordChatExporter.Core.Markdown.Parsing
{
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
internal static partial class MarkdownParser
@@ -120,31 +120,31 @@ namespace DiscordChatExporter.Core.Markdown
// Capture @everyone
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
"@everyone",
_ => new MentionNode("everyone", MentionType.Meta)
_ => new MentionNode("everyone", MentionKind.Meta)
);
// Capture @here
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
"@here",
_ => new MentionNode("here", MentionType.Meta)
_ => new MentionNode("here", MentionKind.Meta)
);
// Capture <@123456> or <@!123456>
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionType.User)
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.User)
);
// Capture <#123456>
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<#(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionType.Channel)
new Regex("<#!?(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Channel)
);
// Capture <@&123456>
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@&(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionType.Role)
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Role)
);
/* Emojis */
@@ -227,6 +227,26 @@ namespace DiscordChatExporter.Core.Markdown
(_, m) => new TextNode(m.Groups[1].Value)
);
/* Misc */
// Capture <t:12345678> or <t:12345678:R>
private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<t:(\\d+)(?::\\w)?>", DefaultRegexOptions),
(_, m) =>
{
// We don't care about the 'R' parameter because we're not going to
// show relative timestamps in an export anyway.
if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture,
out var offset))
{
return null;
}
return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
}
);
// Combine all matchers into one
// Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<MarkdownNode> AggregateNodeMatcher = new AggregateMatcher<MarkdownNode>(
@@ -268,7 +288,10 @@ namespace DiscordChatExporter.Core.Markdown
// Emoji
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher,
CodedStandardEmojiNodeMatcher
CodedStandardEmojiNodeMatcher,
// Misc
UnixTimestampNodeMatcher
);
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
@@ -281,7 +304,10 @@ namespace DiscordChatExporter.Core.Markdown
RoleMentionNodeMatcher,
// Emoji
CustomEmojiNodeMatcher
CustomEmojiNodeMatcher,
// Misc
UnixTimestampNodeMatcher
);
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart, IMatcher<MarkdownNode> matcher) =>
@@ -293,12 +319,16 @@ namespace DiscordChatExporter.Core.Markdown
internal static partial class MarkdownParser
{
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart) => Parse(stringPart, AggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart) =>
Parse(stringPart, AggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringPart stringPart) => Parse(stringPart, MinimalAggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringPart stringPart) =>
Parse(stringPart, MinimalAggregateNodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string input) => Parse(new StringPart(input));
public static IReadOnlyList<MarkdownNode> Parse(string input) =>
Parse(new StringPart(input));
public static IReadOnlyList<MarkdownNode> ParseMinimal(string input) => ParseMinimal(new StringPart(input));
public static IReadOnlyList<MarkdownNode> ParseMinimal(string input) =>
ParseMinimal(new StringPart(input));
}
}
@@ -1,12 +1,12 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Markdown.Ast;
namespace DiscordChatExporter.Core.Markdown
namespace DiscordChatExporter.Core.Markdown.Parsing
{
internal abstract class MarkdownVisitor
{
protected virtual MarkdownNode VisitText(TextNode text) => text;
protected virtual MarkdownNode VisitText(TextNode text) =>
text;
protected virtual MarkdownNode VisitFormatted(FormattedNode formatted)
{
@@ -14,15 +14,23 @@ namespace DiscordChatExporter.Core.Markdown
return formatted;
}
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => inlineCodeBlock;
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) =>
inlineCodeBlock;
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => multiLineCodeBlock;
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) =>
multiLineCodeBlock;
protected virtual MarkdownNode VisitLink(LinkNode link) => link;
protected virtual MarkdownNode VisitLink(LinkNode link) =>
link;
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) => emoji;
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) =>
emoji;
protected virtual MarkdownNode VisitMention(MentionNode mention) => mention;
protected virtual MarkdownNode VisitMention(MentionNode mention) =>
mention;
protected virtual MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) =>
timestamp;
public MarkdownNode Visit(MarkdownNode node) => node switch
{
@@ -33,6 +41,7 @@ namespace DiscordChatExporter.Core.Markdown
LinkNode link => VisitLink(link),
EmojiNode emoji => VisitEmoji(emoji),
MentionNode mention => VisitMention(mention),
UnixTimestampNode timestamp => VisitUnixTimestamp(timestamp),
_ => throw new ArgumentOutOfRangeException(nameof(node))
};
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown.Matching
namespace DiscordChatExporter.Core.Markdown.Parsing
{
internal class ParsedMatch<T>
{
@@ -1,7 +1,7 @@
using System;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Matching
namespace DiscordChatExporter.Core.Markdown.Parsing
{
internal class RegexMatcher<T> : IMatcher<T>
{
@@ -1,6 +1,6 @@
using System;
namespace DiscordChatExporter.Core.Markdown.Matching
namespace DiscordChatExporter.Core.Markdown.Parsing
{
internal class StringMatcher<T> : IMatcher<T>
{
@@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Matching
namespace DiscordChatExporter.Core.Markdown.Parsing
{
internal readonly struct StringPart
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal enum TextFormatting
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown.Ast
namespace DiscordChatExporter.Core.Markdown
{
internal class TextNode : MarkdownNode
{
@@ -0,0 +1,13 @@
using System;
namespace DiscordChatExporter.Core.Markdown
{
internal class UnixTimestampNode : MarkdownNode
{
public DateTimeOffset Value { get; }
public UnixTimestampNode(DateTimeOffset value) => Value = value;
public override string ToString() => Value.ToString();
}
}
@@ -0,0 +1,24 @@
using System;
using Superpower;
using Superpower.Parsers;
namespace DiscordChatExporter.Core.Utils.Extensions
{
public static class SuperpowerExtensions
{
public static TextParser<string> Text(this TextParser<char[]> parser) =>
parser.Select(chars => new string(chars));
public static TextParser<T> Token<T>(this TextParser<T> parser) =>
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
// From: https://twitter.com/nblumhardt/status/1389349059786264578
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) => i =>
{
Console.WriteLine($"Trying {description} ->");
var r = parser(i);
Console.WriteLine($"Result was {r}");
return r;
};
}
}
+6 -4
View File
@@ -21,14 +21,15 @@ namespace DiscordChatExporter.Core.Utils
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
.WaitAndRetryAsync(8,
.WaitAndRetryAsync(
8,
(i, result, _) =>
{
// If rate-limited, use retry-after as a guide
if (result.Result?.StatusCode == HttpStatusCode.TooManyRequests)
{
// Only start respecting retry-after after a few attempts.
// The reason is that Discord often sends unreasonable (20+ minutes) retry-after
// Only start respecting retry-after after a few attempts, because
// Discord often sends unreasonable (20+ minutes) retry-after
// on the very first request.
if (i > 3)
{
@@ -40,7 +41,8 @@ namespace DiscordChatExporter.Core.Utils
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
},
(_, _, _, _) => Task.CompletedTask);
(_, _, _, _) => Task.CompletedTask
);
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex) =>
// This is extremely frail, but there's no other way
@@ -0,0 +1,9 @@
// ReSharper disable CheckNamespace
// TODO: remove after moving to .NET 5
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit
{
}
}
@@ -25,6 +25,8 @@ namespace DiscordChatExporter.Gui.Services
public string? LastPartitionLimitValue { get; set; }
public string? LastMessageFilterValue { get; set; }
public bool LastShouldDownloadMedia { get; set; }
public SettingsService()
@@ -4,6 +4,7 @@ using System.Linq;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Services;
@@ -51,7 +52,13 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
public PartitionLimit PartitionLimit => !string.IsNullOrWhiteSpace(PartitionLimitValue)
? PartitionLimit.Parse(PartitionLimitValue)
: NullPartitionLimit.Instance;
: PartitionLimit.Null;
public string? MessageFilterValue { get; set; }
public MessageFilter MessageFilter => !string.IsNullOrWhiteSpace(MessageFilterValue)
? MessageFilter.Parse(MessageFilterValue)
: MessageFilter.Null;
public bool ShouldDownloadMedia { get; set; }
@@ -61,6 +68,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
After != default ||
Before != default ||
!string.IsNullOrWhiteSpace(PartitionLimitValue) ||
!string.IsNullOrWhiteSpace(MessageFilterValue) ||
ShouldDownloadMedia != default;
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
@@ -71,6 +79,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
// Persist preferences
SelectedFormat = _settingsService.LastExportFormat;
PartitionLimitValue = _settingsService.LastPartitionLimitValue;
MessageFilterValue = _settingsService.LastMessageFilterValue;
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
}
@@ -79,6 +88,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
// Persist preferences
_settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimitValue = PartitionLimitValue;
_settingsService.LastMessageFilterValue = MessageFilterValue;
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
// If single channel - prompt file path
@@ -0,0 +1,27 @@
using DiscordChatExporter.Gui.ViewModels.Framework;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs
{
public class MessageBoxViewModel : DialogScreen
{
public string? Title { get; set; }
public string? Message { get; set; }
}
public static class MessageBoxViewModelExtensions
{
public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory,
string title,
string message)
{
var viewModel = factory.CreateMessageBoxViewModel();
viewModel.Title = title;
viewModel.Message = message;
return viewModel;
}
}
}
@@ -7,6 +7,8 @@ namespace DiscordChatExporter.Gui.ViewModels.Framework
{
ExportSetupViewModel CreateExportSetupViewModel();
MessageBoxViewModel CreateMessageBoxViewModel();
SettingsViewModel CreateSettingsViewModel();
}
}
@@ -168,7 +168,8 @@ namespace DiscordChatExporter.Gui.ViewModels
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync())
{
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
var channels = await discord.GetGuildChannelsAsync(guild.Id);
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
}
GuildChannelMap = guildChannelMap;
@@ -178,6 +179,15 @@ namespace DiscordChatExporter.Gui.ViewModels
{
Notifications.Enqueue(ex.Message.TrimEnd('.'));
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Error pulling guilds and channels",
ex.ToString()
);
await _dialogManager.ShowDialogAsync(dialog);
}
}
public bool CanExportChannels =>
@@ -185,55 +195,68 @@ namespace DiscordChatExporter.Gui.ViewModels
public async void ExportChannels()
{
var token = _settingsService.LastToken;
if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
var exporter = new ChannelExporter(token);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0;
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
try
{
var (channel, operation) = tuple;
var token = _settingsService.LastToken;
if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
return;
try
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
var exporter = new ChannelExporter(token);
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0;
await dialog.Channels.Zip(operations).ParallelForEachAsync(async tuple =>
{
var request = new ExportRequest(
dialog.Guild!,
channel!,
dialog.OutputPath!,
dialog.SelectedFormat,
dialog.After?.Pipe(Snowflake.FromDate),
dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit,
dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia,
_settingsService.DateFormat
);
var (channel, operation) = tuple;
await exporter.ExportChannelAsync(request, operation);
try
{
var request = new ExportRequest(
dialog.Guild!,
channel!,
dialog.OutputPath!,
dialog.SelectedFormat,
dialog.After?.Pipe(Snowflake.FromDate),
dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit,
dialog.MessageFilter,
dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia,
_settingsService.DateFormat
);
Interlocked.Increment(ref successfulExportCount);
}
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
{
Notifications.Enqueue(ex.Message.TrimEnd('.'));
}
finally
{
operation.Dispose();
}
}, _settingsService.ParallelLimit.ClampMin(1));
await exporter.ExportChannelAsync(request, operation);
// Notify of overall completion
if (successfulExportCount > 0)
Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)");
Interlocked.Increment(ref successfulExportCount);
}
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
{
Notifications.Enqueue(ex.Message.TrimEnd('.'));
}
finally
{
operation.Dispose();
}
}, _settingsService.ParallelLimit.ClampMin(1));
// Notify of overall completion
if (successfulExportCount > 0)
Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)");
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Error exporting channel(s)",
ex.ToString()
);
await _dialogManager.ShowDialogAsync(dialog);
}
}
}
}
@@ -131,7 +131,15 @@
materialDesign:HintAssist.Hint="Partition limit"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimitValue}"
ToolTip="Split output into partitions, each limited to this number of messages (e.g. 100) or file size (e.g. 10mb)" />
ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')" />
<!-- Filtering -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Message filter"
materialDesign:HintAssist.IsFloating="True"
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)">
@@ -0,0 +1,49 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.MessageBoxView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
MinWidth="500"
d:DataContext="{d:DesignInstance Type=dialogs:MessageBoxViewModel}"
d:DesignHeight="450"
d:DesignWidth="800"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Title -->
<TextBlock
Grid.Row="0"
Margin="16"
FontSize="17"
Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding Title}" />
<!-- Message -->
<TextBlock
Grid.Row="1"
Margin="16,0,16,16"
Text="{Binding Message}"
TextWrapping="Wrap" />
<!-- Close -->
<Button
Grid.Row="2"
Margin="8"
HorizontalAlignment="Right"
Command="{s:Action Close}"
Content="CLOSE"
IsCancel="True"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}" />
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
namespace DiscordChatExporter.Gui.Views.Dialogs
{
public partial class MessageBoxView
{
public MessageBoxView()
{
InitializeComponent();
}
}
}
+17 -8
View File
@@ -163,23 +163,32 @@
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+M" />
<Run Text="to toggle device toolbar" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="4. Select" />
<Run Text="5. On the left, expand" />
<Run FontWeight="SemiBold" Text="Local Storage" />
<Run Text="&gt;" />
<Run Text="and select" />
<Run FontWeight="SemiBold" Text="https://discord.com" />
<Run Text="on the left" />
<LineBreak />
<Run Text="5. Press" />
<Run Text="6. Type" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="into the" />
<Run FontWeight="SemiBold" Text="Filter" />
<Run Text="box" />
<LineBreak />
<Run Text="7. If the token key does not appear, press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="6. Find" />
<Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="at the bottom and copy the value" />
<Run Text="key" />
</TextBlock>
<TextBlock Margin="0,24,0,0" FontSize="14">
<Run Text="Automating user accounts is technically against TOS, use at your own risk." />
@@ -384,4 +393,4 @@
</Grid>
</Grid>
</materialDesign:DialogHost>
</Window>
</Window>
+32 -2
View File
@@ -61,12 +61,12 @@ The following table lists all available download options:
</tbody>
</table>
- 🦄 - Community-maintained resource
- ⚙️ - Requires .NET runtime to be installed manually:
- [.NET Core v3.1 Runtime for **Windows x64**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x64-installer)
- [.NET Core v3.1 Runtime for **Windows x86**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-desktop-3.1.0-windows-x86-installer)
- [.NET Core v3.1 Runtime for **macOS x64**](https://dotnet.microsoft.com/download/dotnet-core/thank-you/runtime-3.1.0-macos-x64-installer)
- [.NET Core v3.1 Runtime for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find your distro)
- 🦄 - Community-maintained resource
## Features
@@ -74,6 +74,7 @@ The following table lists all available download options:
- Command line interface (Windows, Linux, macOS)
- Support for both user and bot tokens
- Support for Discord's dialect of markdown
- Support for Discord's message filter syntax
- Support for attachments, embeds, emojis, and other rich media features
- Multiple output formats: HTML (dark/light), TXT, CSV, JSON
- File partitioning, date ranges, and other export options
@@ -82,4 +83,33 @@ The following table lists all available download options:
## Screenshots
![channel list](.screenshots/list.png)
![rendered output](.screenshots/output.png)
![rendered output](.screenshots/output.png)
## Building the project locally
Prerequisites:
- [.NET 5.0 SDK](https://dotnet.microsoft.com/download/dotnet/5.0)
- [.NET 3.1 SDK](https://dotnet.microsoft.com/download/dotnet/3.1) (temporarily as the app still targets .NET 3.1, but requires .NET 5.0 to build)
- _(Recommended)_ C#/.NET IDE, such as [JetBrains Rider](https://www.jetbrains.com/rider), [VS Code](https://code.visualstudio.com/docs/languages/csharp), or [Visual Studio](https://visualstudio.microsoft.com).
To build the entire solution run the following command in the root of the repository:
```sh
> dotnet build
```
This will generate runtime artifacts for each project:
```plaintext
./DiscordChatExporter.Gui/bin/[Debug|Release]/[runtime]/*
./DiscordChatExporter.Cli/bin/[Debug|Release]/[runtime]/*
```
You can also build and run a specific project directly.
To do that, navigate to its directory and use `dotnet run`:
```sh
> cd DiscordChatExporter.Gui
> dotnet run
```