mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e849c9516c | |||
| 524d0a1bb2 | |||
| f0a4410ee0 | |||
| a98772e46e | |||
| b6fdea11a2 | |||
| fcca052165 | |||
| ac7ebd6f91 | |||
| 9303383662 | |||
| 7fa908ce34 | |||
| 345dc568c0 | |||
| 5c0b657e0e | |||
| 265cb71dfd | |||
| 0a6fea1ce9 | |||
| ebb36de354 | |||
| 48539084f7 | |||
| 9ec95670c5 | |||
| a449c02dd5 | |||
| 89b1f9924b | |||
| 99f94425c2 | |||
| ab71b035fc | |||
| fdc3c1b012 | |||
| 6fa6df981e | |||
| eec4123b98 | |||
| 6fa7cbe568 | |||
| aae3790a5f | |||
| 28b2039a33 | |||
| f40589dea6 | |||
| bb4db21b87 | |||
| 38bc259ab6 | |||
| 530e2c50a9 | |||
| 63c92047a1 | |||
| 7d9d3ca5fb | |||
| cad6baba48 | |||
| 9c25bceabf | |||
| 28de1053f6 | |||
| ec0494c038 | |||
| d735d2acd3 | |||
| 85d53d0e94 | |||
| daa8c0a735 | |||
| d4eba75036 | |||
| de773467b6 | |||
| 92766b580b | |||
| abf7498667 | |||
| ae42554621 | |||
| b58e67776c | |||
| 7fc6e0cfe0 | |||
| 35264e8a12 | |||
| 2ab6773c17 | |||
| 650c55bbd1 | |||
| e1726683f8 | |||
| 70a1755c50 | |||
| 524b30b09b | |||
| d39fc212b6 | |||
| 95cd6cb50c | |||
| 79a9ac0462 | |||
| 5b5a53dbf4 | |||
| 2b0fa4213b | |||
| fd9d367bad | |||
| dbc3209160 | |||
| 3bcde0c534 | |||
| 903882e35b | |||
| 3c6d9d74a4 | |||
| 1e53f50ca3 |
@@ -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
|
||||
@@ -1,8 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "\U0001F4D6 Read the wiki"
|
||||
- name: 💬 Discord server
|
||||
url: https://discord.gg/2SUWKFnHSm
|
||||
about: Chat with the project community.
|
||||
- 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Docker CD
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.3
|
||||
|
||||
- name: Get release version
|
||||
id: get-version
|
||||
uses: dawidd6/action-get-tag@v1
|
||||
|
||||
- name: Login
|
||||
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
run: docker build -t tyrrrz/discordchatexporter:${{ steps.get-version.outputs.tag }} -t tyrrrz/discordchatexporter:stable .
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
docker push tyrrrz/discordchatexporter:${{ steps.get-version.outputs.tag }}
|
||||
docker push tyrrrz/discordchatexporter:stable
|
||||
@@ -13,6 +13,10 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.3
|
||||
|
||||
- name: Get release version
|
||||
id: get-version
|
||||
uses: dawidd6/action-get-tag@v1
|
||||
|
||||
- name: Install .NET (v3.1)
|
||||
uses: actions/setup-dotnet@v1.7.2
|
||||
with:
|
||||
@@ -69,3 +73,17 @@ jobs:
|
||||
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
|
||||
asset_name: DiscordChatExporter.zip
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Notify Discord
|
||||
uses: satak/webrequest-action@v1.2.4
|
||||
with:
|
||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
method: POST
|
||||
headers: |
|
||||
{
|
||||
"ContentType": "application/json; charset=UTF-8"
|
||||
}
|
||||
payload: |
|
||||
{
|
||||
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get-version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get-version.outputs.tag }}/Changelog.md>"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Docker CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2.3.3
|
||||
|
||||
- name: Login
|
||||
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build
|
||||
run: docker build -t tyrrrz/discordchatexporter:latest .
|
||||
|
||||
- name: Deploy
|
||||
run: docker push tyrrrz/discordchatexporter:latest
|
||||
@@ -20,6 +20,17 @@ jobs:
|
||||
with:
|
||||
dotnet-version: 5.0.x
|
||||
|
||||
- name: Build & test
|
||||
run: dotnet test --configuration Release --logger GitHubActions
|
||||
env:
|
||||
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
||||
DISCORD_TOKEN_BOT: true
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1.0.5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Build & publish (CLI)
|
||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||
|
||||
@@ -36,4 +47,4 @@ jobs:
|
||||
uses: actions/upload-artifact@v1
|
||||
with:
|
||||
name: DiscordChatExporter
|
||||
path: DiscordChatExporter.Gui/bin/Publish/
|
||||
path: DiscordChatExporter.Gui/bin/Publish/
|
||||
+4
-1
@@ -19,4 +19,7 @@ bld/
|
||||
[Oo]bj/
|
||||
|
||||
# Coverage
|
||||
*.opencover.xml
|
||||
*.opencover.xml
|
||||
|
||||
# Secrets
|
||||
*.secret
|
||||
+43
-11
@@ -1,8 +1,40 @@
|
||||
### v2.30.1 (28-Aug-2021)
|
||||
|
||||
- [HTML] Changed user avatars to render using higher resolution images. (Thanks [@Chris Helder](https://github.com/TheDude53))
|
||||
- Fixed an issue where `has:file` message filter was not recognized. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- Fixed an issue where a unix timestamp marker with an invalid offset crashed the export process.
|
||||
|
||||
### v2.30 (08-Aug-2021)
|
||||
|
||||
- [HTML] Added special casing for Spotify track embeds to render them directly using an embedded player.
|
||||
- [HTML] Added special casing for plain image embeds to render them like image attachments.
|
||||
- [HTML] Added tooltip text for unix timestamp markers which shows the full date represented by the timestamp, regardless of configured date format.
|
||||
- [HTML] Updated the fallback text shown when the referenced message could not be loaded to make it more explicit.
|
||||
- [HTML] Updated styling of quoted text in markdown to more closely resemble how it looks in Discord.
|
||||
- [HTML] Fixed an issue where some file formats were not correctly recognized as image or video attachments, which affected how they were rendered.
|
||||
- [HTML] Fixed an issue where formatting inside named links (e.g. `[**Bold link**](https://example.com)`) was not being parsed.
|
||||
- [HTML] Fixed an issue where inline embed fields were missing horizontal spacing between columns.
|
||||
- [HTML] Fixed other minor styling issues.
|
||||
|
||||
### 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 +128,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 +167,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 +180,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 +221,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 +244,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 +312,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 +336,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.
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<Version>2.28</Version>
|
||||
<Version>2.30.1</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<CollectCoverage>true</CollectCoverage>
|
||||
<CoverletOutputFormat>opencover</CoverletOutputFormat>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="0.16.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.1.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
|
||||
<PackageReference Include="coverlet.msbuild" Version="3.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.4.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.TestData;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests
|
||||
{
|
||||
public class EmbedSpecs : IClassFixture<TempOutputFixture>
|
||||
{
|
||||
private readonly ITestOutputHelper _testOutput;
|
||||
private readonly TempOutputFixture _tempOutput;
|
||||
|
||||
public EmbedSpecs(ITestOutputHelper testOutput, TempOutputFixture tempOutput)
|
||||
{
|
||||
_testOutput = testOutput;
|
||||
_tempOutput = tempOutput;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_with_an_embed_is_rendered_correctly_in_JSON()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("json");
|
||||
|
||||
// Act
|
||||
var jsonData = await GlobalCache.WrapAsync("embed-specs-output-json", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.EmbedTestCases)},
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(jsonData);
|
||||
|
||||
var json = Json.Parse(jsonData);
|
||||
|
||||
var messageJson = json
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Single(j => string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
"866769910729146400",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
));
|
||||
|
||||
var embed = messageJson
|
||||
.GetProperty("embeds")
|
||||
.EnumerateArray()
|
||||
.Single();
|
||||
|
||||
var embedAuthor = embed.GetProperty("author");
|
||||
var embedThumbnail = embed.GetProperty("thumbnail");
|
||||
var embedFooter = embed.GetProperty("footer");
|
||||
var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
|
||||
|
||||
// Assert
|
||||
embed.GetProperty("title").GetString().Should().Be("Embed title");
|
||||
embed.GetProperty("url").GetString().Should().Be("https://example.com");
|
||||
embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
|
||||
embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
|
||||
embed.GetProperty("color").GetString().Should().Be("#58B9FF");
|
||||
embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
|
||||
embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
|
||||
embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
|
||||
embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);
|
||||
embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
|
||||
embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
embedFields.Should().HaveCount(3);
|
||||
embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
|
||||
embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
|
||||
embedFields[0].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
||||
embedFields[1].GetProperty("name").GetString().Should().Be("Field 2");
|
||||
embedFields[1].GetProperty("value").GetString().Should().Be("Value 2");
|
||||
embedFields[1].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
||||
embedFields[2].GetProperty("name").GetString().Should().Be("Field 3");
|
||||
embedFields[2].GetProperty("value").GetString().Should().Be("Value 3");
|
||||
embedFields[2].GetProperty("isInline").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_with_an_embed_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("embed-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.EmbedTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866769910729146400");
|
||||
var messageText = messageHtml?.Text();
|
||||
|
||||
// Assert
|
||||
messageText.Should().ContainAll(
|
||||
"Embed author",
|
||||
"Embed title",
|
||||
"Embed description",
|
||||
"Field 1", "Value 1",
|
||||
"Field 2", "Value 2",
|
||||
"Field 3", "Value 3",
|
||||
"Embed footer"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_with_a_Spotify_track_is_rendered_using_an_iframe_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("embed-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.EmbedTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-867886632203976775");
|
||||
var iframeHtml = messageHtml?.QuerySelector("iframe");
|
||||
|
||||
// Assert
|
||||
iframeHtml.Should().NotBeNull();
|
||||
iframeHtml?.GetAttribute("src").Should()
|
||||
.StartWithEquivalentOf("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_with_a_YouTube_video_is_rendered_using_an_iframe_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("embed-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.EmbedTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866472508588294165");
|
||||
var iframeHtml = messageHtml?.QuerySelector("iframe");
|
||||
|
||||
// Assert
|
||||
iframeHtml.Should().NotBeNull();
|
||||
iframeHtml?.GetAttribute("src").Should()
|
||||
.StartWithEquivalentOf("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Fixtures
|
||||
{
|
||||
public class TempOutputFixture : IDisposable
|
||||
{
|
||||
public string DirPath => Path.Combine(
|
||||
Path.GetDirectoryName(typeof(TempOutputFixture).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
||||
"Temp",
|
||||
Guid.NewGuid().ToString()
|
||||
);
|
||||
|
||||
public TempOutputFixture() => Directory.CreateDirectory(DirPath);
|
||||
|
||||
public string GetTempFilePath() => Path.Combine(DirPath, Guid.NewGuid().ToString());
|
||||
|
||||
public string GetTempFilePath(string extension) => Path.ChangeExtension(GetTempFilePath(), extension);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(DirPath, true);
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Infra
|
||||
{
|
||||
internal static class Secrets
|
||||
{
|
||||
private static readonly Lazy<string> DiscordTokenLazy = new(() =>
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
||||
return fromEnvironment;
|
||||
|
||||
var secretFilePath = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
||||
"DiscordToken.secret"
|
||||
);
|
||||
|
||||
if (File.Exists(secretFilePath))
|
||||
return File.ReadAllText(secretFilePath);
|
||||
|
||||
throw new InvalidOperationException("Discord token not provided for tests.");
|
||||
});
|
||||
|
||||
private static readonly Lazy<bool> IsDiscordTokenBotLazy = new(() =>
|
||||
{
|
||||
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
||||
return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var secretFilePath = Path.Combine(
|
||||
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
||||
"DiscordTokenBot.secret"
|
||||
);
|
||||
|
||||
if (File.Exists(secretFilePath))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
public static string DiscordToken => DiscordTokenLazy.Value;
|
||||
|
||||
public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.TestData;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests
|
||||
{
|
||||
public class MentionSpecs : IClassFixture<TempOutputFixture>
|
||||
{
|
||||
private readonly ITestOutputHelper _testOutput;
|
||||
private readonly TempOutputFixture _tempOutput;
|
||||
|
||||
public MentionSpecs(ITestOutputHelper testOutput, TempOutputFixture tempOutput)
|
||||
{
|
||||
_testOutput = testOutput;
|
||||
_tempOutput = tempOutput;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_mention_is_rendered_correctly_in_JSON()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("json");
|
||||
|
||||
// Act
|
||||
var jsonData = await GlobalCache.WrapAsync("mention-specs-output-json", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(jsonData);
|
||||
|
||||
var json = Json.Parse(jsonData);
|
||||
|
||||
var messageJson = json
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Single(j => string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
"866458840245076028",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
));
|
||||
|
||||
// Assert
|
||||
messageJson.GetProperty("content").GetString().Should().Be("User mention: @Tyrrrz");
|
||||
|
||||
messageJson
|
||||
.GetProperty("mentions")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("id").GetString())
|
||||
.Should().Contain("128178626683338752");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_mention_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("mention-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866458840245076028");
|
||||
|
||||
// Assert
|
||||
messageHtml.Should().NotBeNull();
|
||||
messageHtml?.Text().Trim().Should().Be("User mention: @Tyrrrz");
|
||||
messageHtml?.InnerHtml.Should().Contain("Tyrrrz#5447");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Text_channel_mention_is_rendered_correctly_in_JSON()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("json");
|
||||
|
||||
// Act
|
||||
var jsonData = await GlobalCache.WrapAsync("mention-specs-output-json", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(jsonData);
|
||||
|
||||
var json = Json.Parse(jsonData);
|
||||
|
||||
var messageJson = json
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Single(j => string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
"866459040480624680",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
));
|
||||
|
||||
// Assert
|
||||
messageJson.GetProperty("content").GetString().Should().Be("Text channel mention: #mention-tests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Text_channel_mention_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("mention-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866459040480624680");
|
||||
|
||||
// Assert
|
||||
messageHtml.Should().NotBeNull();
|
||||
messageHtml?.Text().Trim().Should().Be("Text channel mention: #mention-tests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Voice_channel_mention_is_rendered_correctly_in_JSON()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("json");
|
||||
|
||||
// Act
|
||||
var jsonData = await GlobalCache.WrapAsync("mention-specs-output-json", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(jsonData);
|
||||
|
||||
var json = Json.Parse(jsonData);
|
||||
|
||||
var messageJson = json
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Single(j => string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
"866459175462633503",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
));
|
||||
|
||||
// Assert
|
||||
messageJson.GetProperty("content").GetString().Should().Be("Voice channel mention: #chaos-vc [voice]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Voice_channel_mention_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("mention-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866459175462633503");
|
||||
|
||||
// Assert
|
||||
messageHtml.Should().NotBeNull();
|
||||
messageHtml?.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Role_mention_is_rendered_correctly_in_JSON()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("json");
|
||||
|
||||
// Act
|
||||
var jsonData = await GlobalCache.WrapAsync("mention-specs-output-json", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(jsonData);
|
||||
|
||||
var json = Json.Parse(jsonData);
|
||||
|
||||
var messageJson = json
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Single(j => string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
"866459254693429258",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
));
|
||||
|
||||
// Assert
|
||||
messageJson.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Role_mention_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("mention-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.MentionTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866459254693429258");
|
||||
|
||||
// Assert
|
||||
messageHtml.Should().NotBeNull();
|
||||
messageHtml?.Text().Trim().Should().Be("Role mention: @Role 1");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# DiscordChatExporter Tests
|
||||
|
||||
This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios.
|
||||
In order to run these tests locally, you need to join the test server and configure your authentication token.
|
||||
|
||||
1. Join the server: https://discord.gg/eRV8Vap5bm
|
||||
2. Locate your Discord authentication token
|
||||
3. Specify your token using a file or an environment variable:
|
||||
- **Using a file**: put your token in a new `DiscordToken.secret` file created in this directory
|
||||
- **Using an environment variable**: set `DISCORD_TOKEN` variable to your token
|
||||
4. Run the tests: `dotnet test`
|
||||
|
||||
> If you're submitting a pull request, you don't _have_ to run the tests locally -- they are executed automatically by CI.
|
||||
Running them locally can still sometimes be useful for debugging purposes though.
|
||||
|
||||
> If you want to have a new test case or a scenario added, please let me know in your pull request.
|
||||
Currently, it's not possible to add them by yourself.
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.TestData;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests
|
||||
{
|
||||
public class ReplySpecs : IClassFixture<TempOutputFixture>
|
||||
{
|
||||
private readonly ITestOutputHelper _testOutput;
|
||||
private readonly TempOutputFixture _tempOutput;
|
||||
|
||||
public ReplySpecs(ITestOutputHelper testOutput, TempOutputFixture tempOutput)
|
||||
{
|
||||
_testOutput = testOutput;
|
||||
_tempOutput = tempOutput;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reply_to_a_normal_message_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("reply-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.ReplyTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866460738239725598");
|
||||
|
||||
// Assert
|
||||
messageHtml.Should().NotBeNull();
|
||||
messageHtml?.Text().Trim().Should().Be("reply to original");
|
||||
messageHtml?.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reply_to_a_deleted_message_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("reply-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.ReplyTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866460975388819486");
|
||||
|
||||
// Assert
|
||||
messageHtml.Should().NotBeNull();
|
||||
messageHtml?.Text().Trim().Should().Be("reply to deleted");
|
||||
messageHtml?.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
||||
.Be("Original message was deleted or could not be loaded.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reply_to_a_empty_message_with_attachment_is_rendered_correctly_in_HTML()
|
||||
{
|
||||
// Arrange
|
||||
var outputFilePath = _tempOutput.GetTempFilePath("html");
|
||||
|
||||
// Act
|
||||
var htmlData = await GlobalCache.WrapAsync("reply-specs-output-html", async () =>
|
||||
{
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
TokenValue = Secrets.DiscordToken,
|
||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
||||
ChannelIds = new[] {Snowflake.Parse(ChannelIds.ReplyTestCases)},
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = outputFilePath
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
return await File.ReadAllTextAsync(outputFilePath);
|
||||
});
|
||||
|
||||
_testOutput.WriteLine(htmlData);
|
||||
|
||||
var html = Html.Parse(htmlData);
|
||||
|
||||
var messageHtml = html.QuerySelector("#message-866462470335627294");
|
||||
|
||||
// Assert
|
||||
messageHtml.Should().NotBeNull();
|
||||
messageHtml?.Text().Trim().Should().Be("reply to attachment");
|
||||
messageHtml?.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
||||
.Be("Click to see attachment 🖼️");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DiscordChatExporter.Cli.Tests.TestData
|
||||
{
|
||||
public static class ChannelIds
|
||||
{
|
||||
public static string EmbedTestCases => "866472452459462687";
|
||||
|
||||
public static string MentionTestCases => "866458801389174794";
|
||||
|
||||
public static string ReplyTestCases => "866459871934677052";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils
|
||||
{
|
||||
internal static class GlobalCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, object?> Dictionary = new();
|
||||
|
||||
public static async Task<T> WrapAsync<T>(string key, Func<Task<T>> getAsync)
|
||||
{
|
||||
if (Dictionary.TryGetValue(key, out var value) && value is T existing)
|
||||
return existing;
|
||||
|
||||
var result = await getAsync();
|
||||
Dictionary[key] = result;
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using AngleSharp.Html.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils
|
||||
{
|
||||
internal static class Html
|
||||
{
|
||||
private static readonly IHtmlParser Parser = new HtmlParser();
|
||||
|
||||
public static IHtmlDocument Parse(string source) => Parser.ParseDocument(source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
|
||||
"methodDisplayOptions": "all",
|
||||
"methodDisplay": "method"
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -16,8 +16,8 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
|
||||
private AuthToken GetAuthToken() => new(
|
||||
IsBotToken
|
||||
? AuthTokenType.Bot
|
||||
: AuthTokenType.User,
|
||||
? AuthTokenKind.Bot
|
||||
: AuthTokenKind.User,
|
||||
TokenValue
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,14 +7,31 @@ namespace DiscordChatExporter.Cli.Utils.Extensions
|
||||
{
|
||||
internal static class ConsoleExtensions
|
||||
{
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) => AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output)
|
||||
}
|
||||
);
|
||||
private class NoopExclusivityMode : IExclusivityMode
|
||||
{
|
||||
public T Run<T>(Func<T> func) => func();
|
||||
|
||||
public Task<T> Run<T>(Func<Task<T>> func) => func();
|
||||
}
|
||||
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console)
|
||||
{
|
||||
// Don't require exclusivity in tests.
|
||||
// Workaround for https://github.com/spectreconsole/spectre.console/issues/494
|
||||
var exclusivityMode = console is FakeConsole
|
||||
? new NoopExclusivityMode()
|
||||
: null;
|
||||
|
||||
return AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
ExclusivityMode = exclusivityMode
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static Progress CreateProgressTicker(this IConsole console) => console
|
||||
.CreateAnsiConsole()
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public class AuthToken
|
||||
{
|
||||
public AuthTokenType Type { get; }
|
||||
public AuthTokenKind Kind { get; }
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public AuthToken(AuthTokenType type, string value)
|
||||
public AuthToken(AuthTokenKind kind, string value)
|
||||
{
|
||||
Type = type;
|
||||
Kind = kind;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public AuthenticationHeaderValue GetAuthenticationHeader() => Type switch
|
||||
public AuthenticationHeaderValue GetAuthenticationHeader() => Kind switch
|
||||
{
|
||||
AuthTokenType.Bot => new AuthenticationHeaderValue("Bot", Value),
|
||||
AuthTokenKind.Bot => new AuthenticationHeaderValue("Bot", Value),
|
||||
_ => new AuthenticationHeaderValue(Value)
|
||||
};
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public enum AuthTokenType
|
||||
public enum AuthTokenKind
|
||||
{
|
||||
User,
|
||||
Bot
|
||||
@@ -1,8 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
@@ -17,18 +18,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 => FileFormat.IsImage(FileExtension);
|
||||
|
||||
public bool IsVideo => VideoFileExtensions.Contains(Path.GetExtension(FileName));
|
||||
public bool IsVideo => FileFormat.IsVideo(FileExtension);
|
||||
|
||||
public bool IsAudio => AudioFileExtensions.Contains(Path.GetExtension(FileName));
|
||||
public bool IsAudio => FileFormat.IsAudio(FileExtension);
|
||||
|
||||
public bool IsSpoiler =>
|
||||
(IsImage || IsVideo || IsAudio) && FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
|
||||
public FileSize FileSize { get; }
|
||||
|
||||
@@ -48,20 +50,12 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
FileSize = fileSize;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => FileName;
|
||||
}
|
||||
|
||||
public partial class Attachment
|
||||
{
|
||||
private static readonly HashSet<string> ImageFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"};
|
||||
|
||||
private static readonly HashSet<string> VideoFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{".mp4", ".webm"};
|
||||
|
||||
private static readonly HashSet<string> AudioFileExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{".mp3", ".wav", ".ogg", ".flac", ".m4a"};
|
||||
|
||||
public static Attachment Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -12,14 +13,16 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public ChannelType Type { get; }
|
||||
public ChannelKind Kind { get; }
|
||||
|
||||
public bool IsTextChannel => Type is
|
||||
ChannelType.GuildTextChat or
|
||||
ChannelType.DirectTextChat or
|
||||
ChannelType.DirectGroupTextChat or
|
||||
ChannelType.GuildNews or
|
||||
ChannelType.GuildStore;
|
||||
public bool IsTextChannel => Kind is
|
||||
ChannelKind.GuildTextChat or
|
||||
ChannelKind.DirectTextChat or
|
||||
ChannelKind.DirectGroupTextChat or
|
||||
ChannelKind.GuildNews or
|
||||
ChannelKind.GuildStore;
|
||||
|
||||
public bool IsVoiceChannel => !IsTextChannel;
|
||||
|
||||
public Snowflake GuildId { get; }
|
||||
|
||||
@@ -33,7 +36,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
|
||||
public Channel(
|
||||
Snowflake id,
|
||||
ChannelType type,
|
||||
ChannelKind kind,
|
||||
Snowflake guildId,
|
||||
ChannelCategory category,
|
||||
string name,
|
||||
@@ -41,7 +44,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
string? topic)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
Kind = kind;
|
||||
GuildId = guildId;
|
||||
Category = category;
|
||||
Name = name;
|
||||
@@ -49,20 +52,21 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
Topic = topic;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
private static ChannelCategory GetFallbackCategory(ChannelType channelType) => new(
|
||||
private static ChannelCategory GetFallbackCategory(ChannelKind channelKind) => new(
|
||||
Snowflake.Zero,
|
||||
channelType switch
|
||||
channelKind switch
|
||||
{
|
||||
ChannelType.GuildTextChat => "Text",
|
||||
ChannelType.DirectTextChat => "Private",
|
||||
ChannelType.DirectGroupTextChat => "Group",
|
||||
ChannelType.GuildNews => "News",
|
||||
ChannelType.GuildStore => "Store",
|
||||
ChannelKind.GuildTextChat => "Text",
|
||||
ChannelKind.DirectTextChat => "Private",
|
||||
ChannelKind.DirectGroupTextChat => "Group",
|
||||
ChannelKind.GuildNews => "News",
|
||||
ChannelKind.GuildStore => "Store",
|
||||
_ => "Default"
|
||||
},
|
||||
null
|
||||
@@ -73,7 +77,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
var topic = json.GetPropertyOrNull("topic")?.GetString();
|
||||
var type = (ChannelType) json.GetProperty("type").GetInt32();
|
||||
var kind = (ChannelKind) json.GetProperty("type").GetInt32();
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
@@ -85,9 +89,9 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
|
||||
return new Channel(
|
||||
id,
|
||||
type,
|
||||
kind,
|
||||
guildId ?? Guild.DirectMessages.Id,
|
||||
category ?? GetFallbackCategory(type),
|
||||
category ?? GetFallbackCategory(kind),
|
||||
name,
|
||||
position ?? json.GetPropertyOrNull("position")?.GetInt32(),
|
||||
topic
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
@@ -20,6 +21,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
Position = position;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
// Order of enum fields needs to match the order in the docs.
|
||||
public enum ChannelType
|
||||
public enum ChannelKind
|
||||
{
|
||||
GuildTextChat = 0,
|
||||
DirectTextChat,
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
||||
{
|
||||
@@ -41,6 +42,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Common
|
||||
return "bytes";
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}";
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1,12 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object
|
||||
public partial class Embed
|
||||
@@ -55,8 +56,13 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
Footer = footer;
|
||||
}
|
||||
|
||||
public PlainImageEmbedProjection? TryGetPlainImage() => PlainImageEmbedProjection.TryResolve(this);
|
||||
|
||||
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => SpotifyTrackEmbedProjection.TryResolve(this);
|
||||
|
||||
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() => YouTubeVideoEmbedProjection.TryResolve(this);
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Title ?? "<untitled embed>";
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,7 +1,8 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||
public partial class EmbedAuthor
|
||||
@@ -22,6 +23,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
IconProxyUrl = iconProxyUrl;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Name ?? "<unnamed author>";
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,7 +1,8 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||
public partial class EmbedField
|
||||
@@ -19,6 +20,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
IsInline = isInline;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => $"{Name} | {Value}";
|
||||
}
|
||||
|
||||
+3
-1
@@ -1,7 +1,8 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||
public partial class EmbedFooter
|
||||
@@ -19,6 +20,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
IconProxyUrl = iconProxyUrl;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Text;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||
public partial class EmbedImage
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
public partial class PlainImageEmbedProjection
|
||||
{
|
||||
public string Url { get; }
|
||||
|
||||
public PlainImageEmbedProjection(string url) => Url = url;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Url;
|
||||
}
|
||||
|
||||
public partial class PlainImageEmbedProjection
|
||||
{
|
||||
public static PlainImageEmbedProjection? TryResolve(Embed embed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||
return null;
|
||||
|
||||
// Has to be an embed without any data (except URL and image)
|
||||
if (!string.IsNullOrWhiteSpace(embed.Title) ||
|
||||
embed.Timestamp is not null ||
|
||||
embed.Author is not null ||
|
||||
!string.IsNullOrWhiteSpace(embed.Description) ||
|
||||
embed.Fields.Any() ||
|
||||
embed.Footer is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Has to be an image file
|
||||
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
|
||||
return null;
|
||||
|
||||
return new PlainImageEmbedProjection(embed.Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
public partial class SpotifyTrackEmbedProjection
|
||||
{
|
||||
public string TrackId { get; }
|
||||
|
||||
public string Url => $"https://open.spotify.com/embed/track/{TrackId}";
|
||||
|
||||
public SpotifyTrackEmbedProjection(string trackId) => TrackId = trackId;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Url;
|
||||
}
|
||||
|
||||
public partial class SpotifyTrackEmbedProjection
|
||||
{
|
||||
private static string? TryParseTrackId(string embedUrl)
|
||||
{
|
||||
// https://open.spotify.com/track/1LHZMWefF9502NPfArRfvP?si=3efac6ce9be04f0a
|
||||
var trackId = Regex.Match(embedUrl, @"spotify\.com/track/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
||||
if (!string.IsNullOrWhiteSpace(trackId))
|
||||
return trackId;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||
return null;
|
||||
|
||||
var trackId = TryParseTrackId(embed.Url);
|
||||
if (string.IsNullOrWhiteSpace(trackId))
|
||||
return null;
|
||||
|
||||
return new SpotifyTrackEmbedProjection(trackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-11
@@ -1,19 +1,17 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds
|
||||
{
|
||||
public partial class YouTubeVideoEmbedProjection
|
||||
{
|
||||
public string VideoId { get; }
|
||||
|
||||
public string Url { get; }
|
||||
public string Url => $"https://www.youtube.com/embed/{VideoId}";
|
||||
|
||||
public YouTubeVideoEmbedProjection(string videoId, string url)
|
||||
{
|
||||
VideoId = videoId;
|
||||
Url = url;
|
||||
}
|
||||
public YouTubeVideoEmbedProjection(string videoId) => VideoId = videoId;
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Url;
|
||||
}
|
||||
|
||||
@@ -53,9 +51,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
return null;
|
||||
|
||||
var url = $"https://www.youtube.com/embed/{videoId}";
|
||||
|
||||
return new YouTubeVideoEmbedProjection(videoId, url);
|
||||
return new YouTubeVideoEmbedProjection(videoId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -33,6 +34,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
ImageUrl = imageUrl;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
@@ -20,6 +21,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
IconUrl = iconUrl;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
@@ -26,6 +27,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
RoleIds = roleIds;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Nick;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
public enum MessageType
|
||||
public enum MessageKind
|
||||
{
|
||||
Default = 0,
|
||||
RecipientAdd = 1,
|
||||
@@ -27,7 +29,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public MessageType Type { get; }
|
||||
public MessageKind Kind { get; }
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
@@ -55,7 +57,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
|
||||
public Message(
|
||||
Snowflake id,
|
||||
MessageType type,
|
||||
MessageKind kind,
|
||||
User author,
|
||||
DateTimeOffset timestamp,
|
||||
DateTimeOffset? editedTimestamp,
|
||||
@@ -70,7 +72,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
Message? referencedMessage)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
Kind = kind;
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
EditedTimestamp = editedTimestamp;
|
||||
@@ -85,6 +87,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
ReferencedMessage = referencedMessage;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Content;
|
||||
}
|
||||
|
||||
@@ -97,21 +100,21 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")?.GetDateTimeOffset();
|
||||
var type = (MessageType) json.GetProperty("type").GetInt32();
|
||||
var kind = (MessageKind) json.GetProperty("type").GetInt32();
|
||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||
|
||||
var content = type switch
|
||||
var content = kind switch
|
||||
{
|
||||
MessageType.RecipientAdd => "Added a recipient.",
|
||||
MessageType.RecipientRemove => "Removed a recipient.",
|
||||
MessageType.Call =>
|
||||
MessageKind.RecipientAdd => "Added a recipient.",
|
||||
MessageKind.RecipientRemove => "Removed a recipient.",
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {callEndedTimestamp?.Pipe(t => t - timestamp).Pipe(t => (int) t.TotalMinutes) ?? 0} minutes.",
|
||||
MessageType.ChannelNameChange => "Changed the channel name.",
|
||||
MessageType.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageType.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageType.GuildMemberJoin => "Joined the server.",
|
||||
MessageKind.ChannelNameChange => "Changed the channel name.",
|
||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
_ => json.GetPropertyOrNull("content")?.GetString() ?? ""
|
||||
};
|
||||
|
||||
@@ -133,7 +136,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
|
||||
return new Message(
|
||||
id,
|
||||
type,
|
||||
kind,
|
||||
author,
|
||||
timestamp,
|
||||
editedTimestamp,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
@@ -20,6 +21,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
GuildId = guildId;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => MessageId?.ToString() ?? "<unknown reference>";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
@@ -16,6 +17,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
Count = count;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => $"{Emoji} ({Count})";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Drawing;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -29,6 +30,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
Color = color;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -37,6 +38,7 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
AvatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => FullName;
|
||||
}
|
||||
|
||||
@@ -47,12 +49,11 @@ namespace DiscordChatExporter.Core.Discord.Data
|
||||
|
||||
private static string GetAvatarUrl(Snowflake id, string avatarHash)
|
||||
{
|
||||
// Animated
|
||||
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif?size=40";
|
||||
var extension = avatarHash.StartsWith("a_", StringComparison.Ordinal)
|
||||
? "gif"
|
||||
: "png";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png?size=40";
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=128";
|
||||
}
|
||||
|
||||
public static User Parse(JsonElement json)
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -126,7 +126,7 @@ namespace DiscordChatExporter.Core.Discord
|
||||
.ToArray();
|
||||
|
||||
var categories = responseOrdered
|
||||
.Where(j => j.GetProperty("type").GetInt32() == (int) ChannelType.GuildCategory)
|
||||
.Where(j => j.GetProperty("type").GetInt32() == (int) ChannelKind.GuildCategory)
|
||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
||||
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -14,6 +15,7 @@ namespace DiscordChatExporter.Core.Discord
|
||||
(long) ((Value >> 22) + 1420070400000UL)
|
||||
).ToLocalTime();
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,18 +6,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonExtensions" Version="1.1.0" />
|
||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.1.2" />
|
||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.1.4" />
|
||||
<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,107 @@
|
||||
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("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound))
|
||||
))
|
||||
.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
|
||||
{
|
||||
@@ -75,35 +81,42 @@
|
||||
else
|
||||
{
|
||||
<span class="chatlog__reference-unknown">
|
||||
Original message was deleted.
|
||||
Original message was deleted or could not be loaded.
|
||||
</span>
|
||||
}
|
||||
</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,207 +125,300 @@
|
||||
</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)
|
||||
{
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</a>
|
||||
}
|
||||
else if (attachment.IsVideo)
|
||||
{
|
||||
<video controls class="chatlog__attachment-thumbnail">
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</video>
|
||||
}
|
||||
else if (attachment.IsAudio)
|
||||
{
|
||||
<audio controls class="chatlog__attachment-thumbnail">
|
||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
|
||||
</audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
var youTubeVideo = embed.TryGetYouTubeVideo();
|
||||
|
||||
<div class="chatlog__embed">
|
||||
@if (embed.Color is not null)
|
||||
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
|
||||
@{/* Spoiler caption */}
|
||||
@if (attachment.IsSpoiler)
|
||||
{
|
||||
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__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__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
}
|
||||
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@if (embed.Author 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">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
<span class="chatlog__embed-author-name">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
{
|
||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@embed.Author.Name
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (youTubeVideo is not null)
|
||||
{
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideo.Url" width="400" height="225"></iframe>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (embed.Fields.Any())
|
||||
{
|
||||
<div class="chatlog__embed-fields">
|
||||
@foreach (var field in embed.Fields)
|
||||
{
|
||||
var isInlineStyle = field.IsInline ? "chatlog__embed-field--inline" : null;
|
||||
|
||||
<div class="chatlog__embed-field @isInlineStyle">
|
||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
{
|
||||
<div class="chatlog__embed-field-name">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
<div class="chatlog__embed-field-value">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (embed.Thumbnail 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">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@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">
|
||||
<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)">
|
||||
@attachment.FileName
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (embed.Footer is not null || embed.Timestamp is not null)
|
||||
{
|
||||
<div class="chatlog__embed-footer">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@if (embed.Timestamp is not null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
<div class="chatlog__attachment-generic-size">
|
||||
@attachment.FileSize
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embeds */}
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
// Plain image embed
|
||||
if (embed.TryGetPlainImage() is { } plainImageEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
<a href="@await ResolveUrlAsync(plainImageEmbed.Url)">
|
||||
<img class="chatlog__embed-plainimage" src="@await ResolveUrlAsync(plainImageEmbed.Url)" alt="Embedded image" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
// Spotify embed
|
||||
else if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
<div class="chatlog__embed-spotify-container">
|
||||
<iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
// YouTube embed
|
||||
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
}
|
||||
|
||||
<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" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
<span class="chatlog__embed-author-name">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
{
|
||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@embed.Author.Name
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Video player */}
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
// Generic embed
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
}
|
||||
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@{/* Embed author */}
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
<span class="chatlog__embed-author-name">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
{
|
||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@embed.Author.Name
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed description */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed fields */}
|
||||
@if (embed.Fields.Any())
|
||||
{
|
||||
<div class="chatlog__embed-fields">
|
||||
@foreach (var field in embed.Fields)
|
||||
{
|
||||
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
|
||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
{
|
||||
<div class="chatlog__embed-field-name">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
<div class="chatlog__embed-field-value">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@{/* Embed content */}
|
||||
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</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" 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" loading="lazy">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@{/* Footer text */}
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@{/* Embed timestamp */}
|
||||
@if (embed.Timestamp is not null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</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,611 @@
|
||||
<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 {
|
||||
display: flex;
|
||||
margin: 0.05em 0;
|
||||
}
|
||||
|
||||
.quote::before {
|
||||
content: "";
|
||||
margin-right: 0.5em;
|
||||
border: 2px 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.3em;
|
||||
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;
|
||||
gap: 0 0.5em;
|
||||
}
|
||||
|
||||
.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-plainimage {
|
||||
vertical-align: top;
|
||||
max-width: 45vw;
|
||||
max-height: 500px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed-spotify {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.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: -.1em;
|
||||
margin-left: 0.3em;
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 3px;
|
||||
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 +637,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
@{/* Scripts */}
|
||||
<script>
|
||||
function scrollToMessage(event, id) {
|
||||
var element = document.getElementById('message-' + id);
|
||||
@@ -56,18 +659,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 +715,5 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@{/* Preamble cuts off at this point */}
|
||||
<div class="chatlog">
|
||||
@@ -25,6 +25,8 @@ namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
{
|
||||
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
||||
|
||||
// We are not writing directly to output because Razor
|
||||
// does not actually do asynchronous writes to stream.
|
||||
await _writer.WriteLineAsync(
|
||||
await PreambleTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
@@ -34,6 +36,8 @@ namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
{
|
||||
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
||||
|
||||
// We are not writing directly to output because Razor
|
||||
// does not actually do asynchronous writes to stream.
|
||||
await _writer.WriteLineAsync(
|
||||
await MessageGroupTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
@@ -66,6 +70,8 @@ namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
|
||||
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
||||
|
||||
// We are not writing directly to output because Razor
|
||||
// does not actually do asynchronous writes to stream.
|
||||
await _writer.WriteLineAsync(
|
||||
await PostambleTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Writing;
|
||||
@@ -192,7 +193,7 @@ namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
// Channel
|
||||
_writer.WriteStartObject("channel");
|
||||
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
||||
_writer.WriteString("type", Context.Request.Channel.Type.ToString());
|
||||
_writer.WriteString("type", Context.Request.Channel.Kind.ToString());
|
||||
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString());
|
||||
_writer.WriteString("category", Context.Request.Channel.Category.Name);
|
||||
_writer.WriteString("name", Context.Request.Channel.Name);
|
||||
@@ -218,7 +219,7 @@ namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
|
||||
// Metadata
|
||||
_writer.WriteString("id", message.Id.ToString());
|
||||
_writer.WriteString("type", message.Type.ToString());
|
||||
_writer.WriteString("type", message.Kind.ToString());
|
||||
_writer.WriteString("timestamp", message.Timestamp);
|
||||
_writer.WriteString("timestampEdited", message.EditedTimestamp);
|
||||
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
|
||||
|
||||
+56
-45
@@ -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
|
||||
@@ -30,22 +30,22 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
||||
return base.VisitText(text);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitFormatted(FormattedNode formatted)
|
||||
protected override MarkdownNode VisitFormatting(FormattingNode formatting)
|
||||
{
|
||||
var (tagOpen, tagClose) = formatted.Formatting switch
|
||||
var (tagOpen, tagClose) = formatting.Kind switch
|
||||
{
|
||||
TextFormatting.Bold => ("<strong>", "</strong>"),
|
||||
TextFormatting.Italic => ("<em>", "</em>"),
|
||||
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>"),
|
||||
TextFormatting.Quote => ("<div class=\"quote\">", "</div>"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(formatted.Formatting))
|
||||
FormattingKind.Bold => ("<strong>", "</strong>"),
|
||||
FormattingKind.Italic => ("<em>", "</em>"),
|
||||
FormattingKind.Underline => ("<u>", "</u>"),
|
||||
FormattingKind.Strikethrough => ("<s>", "</s>"),
|
||||
FormattingKind.Spoiler => (
|
||||
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
|
||||
FormattingKind.Quote => ("<div class=\"quote\">", "</div>"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
|
||||
};
|
||||
|
||||
_buffer.Append(tagOpen);
|
||||
var result = base.VisitFormatted(formatted);
|
||||
var result = base.VisitFormatting(formatting);
|
||||
_buffer.Append(tagClose);
|
||||
|
||||
return result;
|
||||
@@ -75,17 +75,48 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
||||
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitLink(LinkNode link)
|
||||
{
|
||||
// Try to extract message ID if the link refers to a Discord message
|
||||
var linkedMessageId = Regex.Match(
|
||||
link.Url,
|
||||
"^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$"
|
||||
).Groups[1].Value;
|
||||
|
||||
_buffer.Append(
|
||||
!string.IsNullOrWhiteSpace(linkedMessageId)
|
||||
? $"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
|
||||
: $"<a href=\"{Uri.EscapeUriString(link.Url)}\">"
|
||||
);
|
||||
|
||||
var result = base.VisitLink(link);
|
||||
_buffer.Append("</a>");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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.Type == MentionType.Meta)
|
||||
if (mention.Kind == MentionKind.Meta)
|
||||
{
|
||||
_buffer
|
||||
.Append("<span class=\"mention\">")
|
||||
.Append("@").Append(HtmlEncode(mention.Id))
|
||||
.Append("</span>");
|
||||
}
|
||||
else if (mention.Type == MentionType.User)
|
||||
else if (mention.Kind == MentionKind.User)
|
||||
{
|
||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
||||
var fullName = member?.User.FullName ?? "Unknown";
|
||||
@@ -96,17 +127,18 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
||||
.Append("@").Append(HtmlEncode(nick))
|
||||
.Append("</span>");
|
||||
}
|
||||
else if (mention.Type == MentionType.Channel)
|
||||
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("#").Append(HtmlEncode(name))
|
||||
.Append(symbol).Append(HtmlEncode(name))
|
||||
.Append("</span>");
|
||||
}
|
||||
else if (mention.Type == MentionType.Role)
|
||||
else if (mention.Kind == MentionKind.Role)
|
||||
{
|
||||
var role = mentionId?.Pipe(_context.TryGetRole);
|
||||
var name = role?.Name ?? "deleted-role";
|
||||
@@ -125,38 +157,17 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
||||
return base.VisitMention(mention);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
||||
{
|
||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||
var jumboClass = _isJumbo ? "emoji--large" : "";
|
||||
// Timestamp tooltips always use full date regardless of the configured format
|
||||
var longDateString = timestamp.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt");
|
||||
|
||||
_buffer
|
||||
.Append($"<img class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
||||
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
||||
.Append(HtmlEncode(_context.FormatDate(timestamp.Value)))
|
||||
.Append("</span>");
|
||||
|
||||
return base.VisitEmoji(emoji);
|
||||
}
|
||||
|
||||
protected override MarkdownNode VisitLink(LinkNode link)
|
||||
{
|
||||
// Extract message ID if the link points to a Discord message
|
||||
var linkedMessageId = Regex.Match(link.Url, "^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$").Groups[1].Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(linkedMessageId))
|
||||
{
|
||||
_buffer
|
||||
.Append($"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">")
|
||||
.Append(HtmlEncode(link.Title))
|
||||
.Append("</a>");
|
||||
}
|
||||
else
|
||||
{
|
||||
_buffer
|
||||
.Append($"<a href=\"{Uri.EscapeUriString(link.Url)}\">")
|
||||
.Append(HtmlEncode(link.Title))
|
||||
.Append("</a>");
|
||||
}
|
||||
|
||||
return base.VisitLink(link);
|
||||
return base.VisitUnixTimestamp(timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class FormattedNode : MarkdownNode
|
||||
{
|
||||
public TextFormatting Formatting { get; }
|
||||
|
||||
public IReadOnlyList<MarkdownNode> Children { get; }
|
||||
|
||||
public FormattedNode(TextFormatting formatting, IReadOnlyList<MarkdownNode> children)
|
||||
{
|
||||
Formatting = formatting;
|
||||
Children = children;
|
||||
}
|
||||
|
||||
public override string ToString() => $"<{Formatting}> (+{Children.Count})";
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class LinkNode : MarkdownNode
|
||||
{
|
||||
public string Url { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public LinkNode(string url, string title)
|
||||
{
|
||||
Url = url;
|
||||
Title = title;
|
||||
}
|
||||
|
||||
public LinkNode(string url)
|
||||
: this(url, url)
|
||||
{
|
||||
}
|
||||
|
||||
public override string ToString() => $"<Link> {Title}";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -1,6 +1,7 @@
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
internal class EmojiNode : MarkdownNode
|
||||
{
|
||||
@@ -31,6 +32,7 @@ namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => $"<Emoji> {Name}";
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
internal enum TextFormatting
|
||||
internal enum FormattingKind
|
||||
{
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
internal class FormattingNode : MarkdownNode
|
||||
{
|
||||
public FormattingKind Kind { get; }
|
||||
|
||||
public IReadOnlyList<MarkdownNode> Children { get; }
|
||||
|
||||
public FormattingNode(FormattingKind kind, IReadOnlyList<MarkdownNode> children)
|
||||
{
|
||||
Kind = kind;
|
||||
Children = children;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString()
|
||||
{
|
||||
var childrenFormatted = Children.Count == 1
|
||||
? Children.Single().ToString()
|
||||
: "+" + Children.Count;
|
||||
|
||||
return $"<{Kind}> ({childrenFormatted})";
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -1,4 +1,6 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
internal class InlineCodeBlockNode : MarkdownNode
|
||||
{
|
||||
@@ -9,6 +11,7 @@
|
||||
Code = code;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => $"<Code> {Code}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
internal class LinkNode : MarkdownNode
|
||||
{
|
||||
public string Url { get; }
|
||||
|
||||
public IReadOnlyList<MarkdownNode> Children { get; }
|
||||
|
||||
public LinkNode(string url, IReadOnlyList<MarkdownNode> children)
|
||||
{
|
||||
Url = url;
|
||||
Children = children;
|
||||
}
|
||||
|
||||
public LinkNode(string url)
|
||||
: this(url, new[] {new TextNode(url)})
|
||||
{
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString()
|
||||
{
|
||||
var childrenFormatted = Children.Count == 1
|
||||
? Children.Single().ToString()
|
||||
: "+" + Children.Count;
|
||||
|
||||
return $"<Link> ({childrenFormatted})";
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,20 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
public override string ToString() => $"<{Kind} mention> {Id}";
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user