Compare commits

...

98 Commits

Author SHA1 Message Date
Tyrrrz 1f5407f032 Update version 2023-07-21 02:03:47 +03:00
Tyrrrz 83a63c2ed7 Update NuGet packages 2023-07-21 02:03:32 +03:00
Tyrrrz f33a0ac0a7 Update Onova 2023-07-18 22:12:26 +03:00
Tyrrrz 779b8984d7 Update readme 2023-07-17 00:47:30 +03:00
Tyrrrz 3e44a2e88d Re-add IHasId to Channel 2023-07-16 23:12:57 +03:00
Tyrrrz b44e6087be Update NuGet packages 2023-07-16 23:09:31 +03:00
Tyrrrz 49b96087d3 Clean up 2023-07-16 23:05:53 +03:00
Tyrrrz e175c93038 Refactor 2023-07-16 22:55:36 +03:00
Tyrrrz 0ee1107638 Make password box un-editable 2023-07-16 21:37:40 +03:00
Tyrrrz 81a6d363d1 Use favicon as avatar for Discord webhooks 2023-07-11 23:53:12 +03:00
Tyrrrz 90d71c5b9e Clean up 2023-07-10 20:40:32 +03:00
Adam Slatinský 5e9c7392db Add export timestamp in JSON format (#1102) 2023-07-09 18:22:09 +03:00
Tyrrrz c4137cf77e Hide token using password box 2023-07-09 18:13:41 +03:00
Tyrrrz 51192425b7 Expect channel parents to be null 2023-07-08 19:21:00 +03:00
William Unsworth 8776a6955b Better support for exporting threads (#1046) 2023-07-07 20:33:17 +03:00
Tyrrrz c69211797f Clean up 2023-07-03 18:35:28 +03:00
Adam Slatinský 768fb88c8c Export role details in JSON format (#1101) 2023-07-03 18:18:30 +03:00
Tyrrrz d4f62387a5 Add CLI option to include or exclude voice channels in exportall and exportguild 2023-06-23 21:23:55 +03:00
Tyrrrz bd4cfcdaf6 Update avatar resolving for users without discriminator 2023-06-21 22:05:08 +03:00
Tyrrrz c3abeadf01 Update version 2023-06-09 01:20:55 +03:00
Tyrrrz 86ee9c8d5f Fix deploy in workflow 2023-06-09 01:20:47 +03:00
Tyrrrz c99355dec3 Consistency in parsing 2023-06-09 01:20:39 +03:00
Tyrrrz 11d34109a7 Extract thread status and improve CLI output 2023-06-09 01:20:32 +03:00
Tyrrrz 609ca0fc0d Simplify guide for extracting channel IDs 2023-06-09 01:19:36 +03:00
Tyrrrz 6cadb19cc8 Update NuGet packages 2023-06-09 01:19:12 +03:00
Tyrrrz d02649773e Fix tests 2023-06-08 16:53:03 +03:00
Tyrrrz 18d9c189b7 Update docs with more information on message filter caveats in CLI
Closes #1045
2023-06-08 16:48:32 +03:00
Tyrrrz 3d2d197904 Introduce the foundation for the new username system 2023-06-08 16:37:05 +03:00
Tyrrrz bf0d8ab31e Rename markdown Header to Heading to avoid ambiguity and for consistency with MD/HTML standards 2023-06-02 13:59:39 +03:00
Tyrrrz 30b447d2ae Update NuGet packages 2023-05-31 22:15:24 +03:00
Tyrrrz 3d047321cf Parse YouTube video IDs natively 2023-05-31 22:14:58 +03:00
Tyrrrz 6d787c4071 Collect source information in tests 2023-05-30 01:31:46 +03:00
Tyrrrz d779ed9bc8 Blur the token in GUI when the control is out of focus 2023-05-29 19:01:49 +03:00
Tyrrrz 3319b4734d Use event data to reference repo info in workflows 2023-05-27 23:49:05 +03:00
Tyrrrz e052241713 Simplify workflows 2023-05-27 23:32:24 +03:00
Tyrrrz 2b7f1ed4ed Use granular permissions for GitHub Actions 2023-05-27 22:42:04 +03:00
Tyrrrz 03784f0d7c Update favicon 2023-05-26 20:40:45 +03:00
Tyrrrz e7310274ba Clean up tests 2023-05-25 18:51:49 +03:00
Tyrrrz 4eab781450 Update NuGet packages 2023-05-25 09:13:21 +03:00
Tyrrrz 6582e26cd3 Increase padding in output to accomodate longer snowflakes 2023-05-24 21:54:51 +03:00
Tyrrrz c48899e0b1 Refactor 2023-05-24 21:51:24 +03:00
William Unsworth 41b4e47d0e Use alternative endpoint to retrieve threads when requested by a bot (#1037)
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2023-05-24 19:02:34 +03:00
Tyrrrz 6bbde4ccdc Add support for lists in markdown 2023-05-22 14:14:17 +03:00
Tyrrrz 469a731892 Add support for headers in markdown 2023-05-22 11:05:14 +03:00
Tyrrrz d8315c7827 Rename AsyncExtensions to AsyncCollectionExtensions 2023-05-22 10:08:38 +03:00
Tyrrrz a4111c184e Better test naming 2023-05-20 07:32:21 +03:00
Tyrrrz c945c98f75 Update NuGet package 2023-05-20 07:11:16 +03:00
Tyrrrz 31c7ae9312 Refactor 2023-05-20 07:09:19 +03:00
Tyrrrz 03c5c1bc5e Clean up 2023-05-19 14:49:31 +03:00
Tyrrrz 0ae9062a30 Fix typo and refactor 2023-05-19 02:13:19 +03:00
William Unsworth 25caf04445 Preliminary support for threads (#1032) 2023-05-19 01:46:37 +03:00
Tyrrrz 9048557b17 Short-circuit if trying to export an empty channel or a channel with mismatching boundaries
Closes #1024
2023-05-13 21:45:15 +03:00
Tyrrrz faad994332 Add docker badge 2023-05-11 18:10:54 +03:00
Tyrrrz d7537425f8 Use tar as output for docker to avoid unsupported file paths 2023-05-11 16:17:30 +03:00
Tyrrrz e261edde8b Re-add the Docker build job on GitHub Actions 2023-05-11 16:07:06 +03:00
Tyrrrz 72bb886a5e More explicit names 2023-05-11 01:59:36 +03:00
Tyrrrz 9428d5ed56 Prettify workflow files 2023-05-11 01:06:58 +03:00
Tyrrrz b8b1fdb1d1 Make push work in docker workflow 2023-05-11 00:39:09 +03:00
Tyrrrz 168b3c2f31 Fix docker workflow issues 2023-05-11 00:27:22 +03:00
Tyrrrz 9575fd11b5 Update docker workflow 2023-05-11 00:20:29 +03:00
Tyrrrz a332bda07d Make dockerfile more platform-agnostic 2023-05-11 00:06:30 +03:00
Tyrrrz c3f181c9e0 Clean up the docker file 2023-05-10 22:02:50 +03:00
Tyrrrz b9cd40f211 Fix notify in CI 2023-05-02 19:20:23 +03:00
Tyrrrz ab933a7240 Rename TitleLinkNodeMatcher to MaskedLinkNodeMatcher to align with Discord's own terminology 2023-04-30 20:05:31 +03:00
Tyrrrz a9fc439cc5 Update NuGet packages 2023-04-28 17:20:04 +03:00
Tyrrrz d4f5fc5efc Update NuGet packages 2023-04-24 19:39:32 +03:00
Tyrrrz 505330e117 Consistency 2023-04-14 21:04:21 +03:00
Tyrrrz 36db99b5cf Fix workflows 2023-04-06 11:01:45 +03:00
Tyrrrz a4110a362f Use my own GitHub actions 2023-04-05 19:17:43 +03:00
Tyrrrz 0ab8bd67c4 Clean up 2023-04-02 11:41:43 +03:00
Jamie Dubs 31952a109e Fix broken links to the 'Scheduling-*' guides (#1020) 2023-03-23 21:19:04 +02:00
Tyrrrz 2e1636e6c9 Re-implement changes in the previous commit 2023-03-23 13:23:32 +02:00
Tyrrrz 20e782e6ed Revert "Add env variable fallback for --fuck-russia"
This reverts commit ebb838bb81.

Closes #1019
2023-03-23 02:59:43 +02:00
Tyrrrz ebb838bb81 Add env variable fallback for --fuck-russia 2023-03-15 20:44:24 +02:00
Tyrrrz 966627f289 Update version 2023-03-10 16:35:13 +02:00
Tyrrrz 4ec00d0ec8 Clean up CSS 2023-03-10 16:33:56 +02:00
Tyrrrz 8bc1bcaf16 Render linked videos as embeds in HTML
Related to #1012
2023-03-10 16:17:35 +02:00
Tyrrrz 8f40acdda7 Load mentions for users that left the guild 2023-03-08 12:32:59 +02:00
Mark Cilia Vincenti bbe18368fa Update to AsyncKeyedLock 6.2.0 (#1003) 2023-02-25 20:30:00 +02:00
Tyrrrz 48eece554c Update version 2023-02-25 00:32:14 +02:00
Tyrrrz 7d7f1cc1e5 Update NuGet packages 2023-02-25 00:16:08 +02:00
Tyrrrz 2a1998b81c Add support for group DMs in invite embeds 2023-02-23 19:33:49 +02:00
Tyrrrz ac7f08ff3e Remove redundant explicit RazorBlade build references 2023-02-23 06:15:56 +02:00
Tyrrrz 95c5a43f6e Fix more sticker tests 2023-02-23 01:05:26 +02:00
Tyrrrz 91afded824 Fix sticker tests 2023-02-23 01:04:29 +02:00
Tyrrrz 0981c51fb9 Fix nick fallback 2023-02-23 01:03:25 +02:00
Tyrrrz 55209a0517 Add support for member-level avatars 2023-02-23 00:54:02 +02:00
Tyrrrz c2c35cf3a3 Treat gifv attachments as videos instead of images 2023-02-23 00:45:58 +02:00
Tyrrrz 404542d973 Replace Tyrrrz.Settings with Cogwheel 2023-02-20 02:56:39 +02:00
Tyrrrz f36852a1a2 Quote font URLs in HTML preamble to avoid issues 2023-02-18 20:01:46 +02:00
Tyrrrz 0e1c3e4c76 Add support for extracting channels from data dump in exportall
Closes #597
2023-02-18 19:45:31 +02:00
Tyrrrz 83e3289ead Use the new gg sans font 2023-02-18 18:22:27 +02:00
Tyrrrz b51900ec6a Update readme 2023-02-18 18:12:17 +02:00
Tyrrrz d1647e8286 Add support for selectable assets directory in GUI 2023-02-17 21:30:10 +02:00
Lucas LaBuff 95115f3e99 Add option to specify download directory for assets (#989)
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2023-02-17 20:48:20 +02:00
Tyrrrz 53ff8ba430 Update screenshot 2023-02-16 23:16:04 +02:00
Tyrrrz cbab5fb71c Update changelog again 2023-02-16 23:08:48 +02:00
Tyrrrz 9512729ec2 Update changelog 2023-02-16 22:58:37 +02:00
111 changed files with 1956 additions and 1175 deletions
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+16 -4
View File
@@ -2,7 +2,7 @@
You can use a special notation to filter messages that you want to have included in an export. The notation syntax is designed to mimic Discord's search query syntax, but with additional capabilities. You can use a special notation to filter messages that you want to have included in an export. The notation syntax is designed to mimic Discord's search query syntax, but with additional capabilities.
To configure a filter, specify it in advanced export parameters when using the GUI or by passing the `--filter` option when using the CLI. To configure a filter, specify it in advanced export parameters when using the GUI or by passing the `--filter` option when using the CLI. For the CLI version, see also [caveats](#cli-caveats).
## Examples ## Examples
@@ -84,10 +84,22 @@ from:Tyrrrz | from:"96-LB"
from:96\-LB from:96\-LB
``` ```
## CLI Caveat ## CLI Caveats
Negated filters (those that start with `-`) may cause parsing issues when using the CLI version of DiscordChatExporter. To avoid this, use the tilde (`~`) character instead of the dash (`-`): In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
```
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
```
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
```
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
```
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
``` ```
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
``` ```
+3 -3
View File
@@ -14,9 +14,9 @@
- [How to get Token and Channel IDs](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Token-and-IDs.md) - [How to get Token and Channel IDs](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Token-and-IDs.md)
- [How to use message filters](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md) - [How to use message filters](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md)
- Export scheduling with CLI: - Export scheduling with CLI:
- [Windows](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/scheduling-windows.md) - [Windows](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Scheduling-Windows.md)
- [macOS](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/scheduling-MacOS.md) - [macOS](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Scheduling-MacOS.md)
- [Linux](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/scheduling-Linux.md) - [Linux](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Scheduling-Linux.md)
## Video tutorial ## Video tutorial
+2 -22
View File
@@ -234,29 +234,9 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
--- ---
## How to get a Server ID or a Server Channel ID ## How to get a Server ID or a Channel ID
1. Open Discord Settings 1. Open Discord Settings
2. Go to the **Advanced** section 2. Go to the **Advanced** section
3. Enable **Developer Mode** 3. Enable **Developer Mode**
4. Right-click on the desired server or channel and click Copy ID 4. Right-click on the desired server or channel and click Copy Server ID or Copy Channel ID
## How to get a Direct Message Channel ID
1. Click the three dots next to any message in the channel you want to export.
2. Paste the link into a text editor.
3. Copy the text in the highlighted part of the link.
> E.g. in https//discord.com/channels/@me/**189716987098470**/0985709387059874, you should copy **189716987098470**
Make sure you're not copying the user's ID.
**Alternatively, you can also:**
1. Open the desired direct message channel
2. Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>I</kbd> (<kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>I</kbd> on macOS) on Discord to show developer tools
3. Navigate to the `Console` tab
4. Type `window.location.href` and press Enter
5. Copy the first long sequence of numbers inside the URL
> E.g. in https//discord.com/channels/@me/**84289740160**, you should copy **84289740160**
+51 -20
View File
@@ -5,51 +5,82 @@ on: [push, pull_request]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
actions: write
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Build container - name: Install Docker Buildx
run: docker build -f DiscordChatExporter.Cli.dockerfile . uses: docker/setup-buildx-action@v2
- name: Build image
run: >
docker buildx build
--file DiscordChatExporter.Cli.dockerfile
--platform linux/amd64,linux/arm64
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
.
- name: Upload artifacts
uses: actions/upload-artifact@v3
with:
name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar
deploy-latest: deploy-latest:
if: ${{ github.event_name == 'push' && github.ref_type == 'branch' && github.ref_name == 'master' }} if: ${{ github.event_name == 'push' && github.ref_type == 'branch' && github.ref_name == 'master' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub - name: Login to DockerHub
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }} run: >
echo ${{ secrets.DOCKER_TOKEN }} |
docker login --username tyrrrz --password-stdin
- name: Build container - name: Build & push image
run: docker build -f DiscordChatExporter.Cli.dockerfile -t tyrrrz/discordchatexporter:latest . run: >
docker buildx build
- name: Push container --file DiscordChatExporter.Cli.dockerfile
run: docker push tyrrrz/discordchatexporter:latest --platform linux/amd64,linux/arm64
--push
--tag tyrrrz/discordchatexporter:latest
.
deploy-stable: deploy-stable:
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Get release version - name: Install Docker Buildx
id: get_version uses: docker/setup-buildx-action@v2
uses: dawidd6/action-get-tag@v1
- name: Login to DockerHub - name: Login to DockerHub
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }} run: >
echo ${{ secrets.DOCKER_TOKEN }} |
docker login --username tyrrrz --password-stdin
- name: Build container - name: Build & push image
run: docker build -f DiscordChatExporter.Cli.dockerfile -t tyrrrz/discordchatexporter:stable -t tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }} . run: >
docker buildx build
- name: Push container (stable) --file DiscordChatExporter.Cli.dockerfile
run: docker push tyrrrz/discordchatexporter:stable --platform linux/amd64,linux/arm64
--push
- name: Push container (${{ steps.get_version.outputs.tag }}) --tag tyrrrz/discordchatexporter:stable
run: docker push tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }} --tag tyrrrz/discordchatexporter:${{ github.ref_name }}
.
+53 -26
View File
@@ -5,6 +5,8 @@ on: [push, pull_request]
jobs: jobs:
test: test:
runs-on: windows-latest runs-on: windows-latest
permissions:
contents: read
env: env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
@@ -23,7 +25,14 @@ jobs:
- name: Run tests - name: Run tests
# Tests need access to secrets, so we can't run them against PRs because of limited trust # Tests need access to secrets, so we can't run them against PRs because of limited trust
if: ${{ github.event_name != 'pull_request' }} if: ${{ github.event_name != 'pull_request' }}
run: dotnet test --configuration Release --logger GitHubActions --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover run: >
dotnet test
--configuration Release
--logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true"
--collect:"XPlat Code Coverage"
--
RunConfiguration.CollectSourceInformation=true
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
env: env:
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
@@ -32,9 +41,12 @@ jobs:
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
package: pack:
needs: test needs: test
runs-on: windows-latest runs-on: windows-latest
permissions:
actions: write
contents: read
env: env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
@@ -51,27 +63,36 @@ jobs:
dotnet-version: 7.0.x dotnet-version: 7.0.x
- name: Publish (CLI) - name: Publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release run: >
dotnet publish DiscordChatExporter.Cli
--output DiscordChatExporter.Cli/publish/
--configuration Release
- name: Publish (GUI) - name: Publish (GUI)
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release run: >
dotnet publish DiscordChatExporter.Gui
--output DiscordChatExporter.Gui/publish/
--configuration Release
- name: Upload artifacts (CLI) - name: Upload artifacts (CLI)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: DiscordChatExporter.Cli name: DiscordChatExporter.Cli
path: DiscordChatExporter.Cli/bin/Publish/ path: DiscordChatExporter.Cli/publish/
- name: Upload artifacts (GUI) - name: Upload artifacts (GUI)
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: DiscordChatExporter name: DiscordChatExporter
path: DiscordChatExporter.Gui/bin/Publish/ path: DiscordChatExporter.Gui/publish/
deploy: deploy:
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }} if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
needs: package needs: pack
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
actions: read
contents: write
steps: steps:
- name: Download artifacts (CLI) - name: Download artifacts (CLI)
@@ -87,43 +108,49 @@ jobs:
path: DiscordChatExporter.Gui path: DiscordChatExporter.Gui
- name: Create package (CLI) - name: Create package (CLI)
run: Compress-Archive -Path DiscordChatExporter.Cli/* -DestinationPath DiscordChatExporter.Cli.zip -Force
shell: pwsh shell: pwsh
run: >
Compress-Archive
-Path DiscordChatExporter.Cli/*
-DestinationPath DiscordChatExporter.Cli.zip
-Force
- name: Create package (GUI) - name: Create package (GUI)
run: Compress-Archive -Path DiscordChatExporter.Gui/* -DestinationPath DiscordChatExporter.zip -Force
shell: pwsh shell: pwsh
run: >
Compress-Archive
-Path DiscordChatExporter.Gui/*
-DestinationPath DiscordChatExporter.zip
-Force
- name: Create release - name: Create release
uses: softprops/action-gh-release@v1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: run: >
files: | gh release create "${{ github.ref_name }}"
DiscordChatExporter.Cli.zip "DiscordChatExporter.Cli.zip"
DiscordChatExporter.zip "DiscordChatExporter.zip"
body: | --repo "${{ github.event.repository.full_name }}"
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md) --title "${{ github.ref_name }}"
--notes "[Changelog](${{ github.event.repository.html_url }}/blob/${{ github.ref_name }}/Changelog.md)"
--verify-tag
notify: notify:
needs: deploy needs: deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Get release version
id: get_version
uses: dawidd6/action-get-tag@v1
- name: Notify Discord - name: Notify Discord
uses: satak/webrequest-action@v1.2.4 uses: tyrrrz/action-http-request@v1
with: with:
url: ${{ secrets.DISCORD_WEBHOOK }} url: ${{ secrets.DISCORD_WEBHOOK }}
method: POST method: POST
headers: | headers: |
Content-Type: application/json; charset=UTF-8
body: |
{ {
"ContentType": "application/json; charset=UTF-8" "avatar_url": "https://raw.githubusercontent.com/${{ github.event.repository.full_name }}/${{ github.ref_name }}/favicon.png",
} "content": "**${{ github.event.repository.name }}** new version released!\nVersion: `${{ github.ref_name }}`\nChangelog: <${{ github.event.repository.html_url }}/blob/${{ github.ref_name }}/Changelog.md>"
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>"
} }
+47 -4
View File
@@ -1,18 +1,61 @@
# Changelog # Changelog
## v2.40.1 (21-Jul-2023)
- General changes:
- Fixed an issue where the fallback avatar (i.e. the avatar used for when the user doesn't have a custom one set) was not resolved properly for users that don't have a discriminator.
- CLI changes:
- Added the `--include-vc` option to `exportall` and `exportguild` commands, which instructs whether to include voice-channel-accompanying text channels in the export. By default, this is set to `true` to match the previous behavior. To disable the option, use `--include-vc false`.
- GUI changes:
- Replaced the blur effect applied to the token text box with a password mask.
- JSON format changes:
- Added the `exportedAt` field to the export metadata, which contains the timestamp corresponding to when the export was created. (Thanks [@slatinsky](https://github.com/slatinsky))
- Added the `roles` field to the object describing a user. This field contains an array of objects that describe the roles assigned to the user. (Thanks [@slatinsky](https://github.com/slatinsky))
## v2.40 (08-Jun-2023)
- General changes:
- Added support for users that don't have discriminators.
- Added a precondition check that verifies whether the requested channel contains any messages for the specified period, before starting the export process. This check works by comparing the export bounds against the ID of the last message in the channel, which is provided by the Discord API as part of the channel metadata. When exporting many channels in batches (e.g. using the `exportall` command), this check may eliminate a significant number of unnecessary API calls and speed up the export process.
- CLI changes:
- Extended the `channels` command with the `--include-threads` option. When this option is provided, the command will include channel threads in the output. (Thanks [@WAUthethird](https://github.com/WAUthethird))
- Simplified the information in the `guide` command related to the extraction of DM channel IDs. Discord now provides a native way of copying IDs of DM channels, so the previous devtools-based workaround is no longer necessary.
- GUI changes:
- Added a blur effect to the token text box, which activates when the control loses focus. This is done to reduce the risk of accidentally exposing the token when taking screenshots.
- HTML format changes:
- Added support for the header markdown syntax.
- Added support for the list markdown syntax.
## v2.39.1 (10-Mar-2023)
- Fixed an issue where messages containing mentions of users that left the server (but haven't deleted their account) were incorrectly rendered as `@Unknown`.
- Fixed an issue where messages containing video links were not rendered correctly in the HTML export. Now, they are rendered using a video player and the referenced assets are pulled if the "download assets" option is enabled.
## v2.39 (25-Feb-2023)
- Added an option to specify the directory path for downloaded assets. This can be used, in combination with the "reuse assets" option, to have a shared asset directory for multiple exports. In GUI, this option can be found in the advanced section of the export dialog. In CLI, it's available as `--media-dir <path>`. (Thanks [@96-LB](https://github.com/96-LB))
- Added support for server-level user avatars. If a user has a custom avatar set on the server, it will be rendered instead of their main avatar.
- CLI changes:
- Extended the `exportall` command with the `--data-package` option. You can use this option to provide the path to your personal data package (`package.zip` file), requested from Discord. When provided, the command will pull channels listed in the package instead of using the Discord API to query them. This can be particularly useful if you want to export DM channels that have been closed, as the personal data package should still contain their IDs.
- HTML format changes:
- Added support for rendering group DM channel invites.
- Changed the primary font from `Whitney` to `gg sans` to match the new Discord design.
- Fixed an issue where the external font sometimes failed to load if the export was created with the "download assets" option enabled.
- Fixed an issue where `gifv` attachments were incorrectly treated as images instead of video files.
## v2.38 (16-Feb-2023) ## v2.38 (16-Feb-2023)
- General changes: - General changes:
- Added an option to disable markdown processing when exporting. You can use this to produce JSON or plain text exports without unwrapping mentions, custom emoji, and certain other special tokens. In GUI, this option can be found in the export dialog. In CLI, it's available as the `--markdown <true|false>` flag.
- Added support for different formatting options when rendering timestamps (i.e. the `f` in `<t:1234567890:f>`). As an exception, relative timestamps are formatted as regular timestamps since that makes more sense in a static export. Currently, all formatting options are rendered based on the `English (US)` locale, with the intent to make that more configurable in the future. - Added support for different formatting options when rendering timestamps (i.e. the `f` in `<t:1234567890:f>`). As an exception, relative timestamps are formatted as regular timestamps since that makes more sense in a static export. Currently, all formatting options are rendered based on the `English (US)` locale, with the intent to make that more configurable in the future.
- Added an option to disable markdown processing when exporting. You can use this to produce JSON or plain text exports without unwrapping mentions, custom emoji, and certain other special tokens.
- Moved the "reuse assets" option from the settings dialog to the export dialog.
- Changed the default date format from `dd-MMM-yy hh:mm tt` to `MM/dd/yyyy h:mm tt` to match the date format used by Discord for message timestamps when the `English (US)` language is selected. This should make the default date format more consistent with other non-configurable date formats used by DiscordChatExporter. In the future, this setting will most likely be removed entirely in favor of a more flexible approach. - Changed the default date format from `dd-MMM-yy hh:mm tt` to `MM/dd/yyyy h:mm tt` to match the date format used by Discord for message timestamps when the `English (US)` language is selected. This should make the default date format more consistent with other non-configurable date formats used by DiscordChatExporter. In the future, this setting will most likely be removed entirely in favor of a more flexible approach.
- Updated the usage guide with additional steps required to retrieve the user token. - Updated the usage guide with additional steps required to retrieve the user token.
- Fixed an issue where certain user mentions were incorrectly rendered as `@Unknown`. - Fixed an issue where certain user mentions were incorrectly rendered as `@Unknown`.
- Fixed an issue where some embed images were rendered using their actual URLs instead of Discord proxy URLs. - Fixed an issue where some embed images were rendered using their actual URLs instead of Discord proxy URLs.
- GUI changes: - GUI changes:
- Added a text box for specifying the output path when exporting channels. It can be used to set an output path that uses template tokens, which previously required hacky workarounds. This text box can be left empty, in which case clicking on the "Export" button will open the file or folder picker dialog, matching the current behavior. - Added a text box for specifying the output path when exporting channels. It can be used to set an output path that uses template tokens, which previously required hacky workarounds. This text box can be left empty, in which case clicking on the "Export" button will open the file or folder picker dialog, matching the previous behavior.
- Added a notification when the application is updated to a new version, containing the changelog link. - Added a notification when the application is updated to a new version, containing the changelog link.
- Moved the "reuse assets" option from the settings dialog to the export dialog.
- Replaced the lock icon in the token text box with a key icon, to better reflect the purpose of the input. - Replaced the lock icon in the token text box with a key icon, to better reflect the purpose of the input.
- Minor cosmetic improvements. - Minor cosmetic improvements.
- CLI changes: - CLI changes:
@@ -20,7 +63,7 @@
- HTML format changes: - HTML format changes:
- Added proper support for more system notification messages. - Added proper support for more system notification messages.
- Added support for rendering server invites. Now, if the message contains an invite link, the export will show the server icon and include additional information, such as the name of the server and the target channel. - Added support for rendering server invites. Now, if the message contains an invite link, the export will show the server icon and include additional information, such as the name of the server and the target channel.
- Changed the preamble part of the export to show the channel icon when available, instead of the default Discord logo. This is only available for group DM channels. - Changed the preamble part of the export to show the channel icon when available, instead of the default Discord logo. This is only available for group DM channels. (Thanks [@CanePlayz](https://github.com/CanePlayz))
- Fixed an issue where some miscellaneous characters were incorrectly treated as emoji, which resulted in them being rendered as broken images. (Thanks [@CanePlayz](https://github.com/CanePlayz)) - Fixed an issue where some miscellaneous characters were incorrectly treated as emoji, which resulted in them being rendered as broken images. (Thanks [@CanePlayz](https://github.com/CanePlayz))
- Fixed an issue where messages triggered by app interactions were not rendered as replies. - Fixed an issue where messages triggered by app interactions were not rendered as replies.
- Fixed an issue where server cross-posts were not rendered correctly. - Fixed an issue where server cross-posts were not rendered correctly.
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Version>2.38</Version> <Version>2.40.1</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
@@ -12,18 +12,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.1" /> <PackageReference Include="AngleSharp" Version="1.0.4" />
<PackageReference Include="FluentAssertions" Version="6.10.0" /> <PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" />
<PackageReference Include="ReflectionMagic" Version="4.1.0" /> <PackageReference Include="ReflectionMagic" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.0" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="3.2.0" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -48,23 +48,22 @@ public static class ExportWrapper
var fileName = channelId.ToString() + '.' + format.GetFileExtension(); var fileName = channelId.ToString() + '.' + format.GetFileExtension();
var filePath = Path.Combine(DirPath, fileName); var filePath = Path.Combine(DirPath, fileName);
// Lock separately for each channel and format using var _ = await Locker.LockAsync(filePath);
using (await Locker.LockAsync(filePath)) using var console = new FakeConsole();
{
// Perform export only if it hasn't been done before
if (!File.Exists(filePath))
{
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId },
ExportFormat = format,
OutputPath = filePath
}.ExecuteAsync(new FakeConsole());
}
return await File.ReadAllTextAsync(filePath); // Perform the export only if it hasn't been done before
if (!File.Exists(filePath))
{
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId },
ExportFormat = format,
OutputPath = filePath
}.ExecuteAsync(console);
} }
return await File.ReadAllTextAsync(filePath);
} }
public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse( public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse(
@@ -94,14 +93,13 @@ public static class ExportWrapper
public static async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId) public static async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
{ {
var message = (await GetMessagesAsHtmlAsync(channelId)) var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
.SingleOrDefault(e => string.Equals(
string.Equals( e.GetAttribute("data-message-id"),
e.GetAttribute("data-message-id"), messageId.ToString(),
messageId.ToString(), StringComparison.OrdinalIgnoreCase
StringComparison.OrdinalIgnoreCase )
) );
);
if (message is null) if (message is null)
{ {
@@ -115,14 +113,13 @@ public static class ExportWrapper
public static async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId) public static async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
{ {
var message = (await GetMessagesAsJsonAsync(channelId)) var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
.SingleOrDefault(j => string.Equals(
string.Equals( j.GetProperty("id").GetString(),
j.GetProperty("id").GetString(), messageId.ToString(),
messageId.ToString(), StringComparison.OrdinalIgnoreCase
StringComparison.OrdinalIgnoreCase )
) );
);
if (message.ValueKind == JsonValueKind.Undefined) if (message.ValueKind == JsonValueKind.Undefined)
{ {
@@ -8,14 +8,14 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class CsvContentSpecs public class CsvContentSpecs
{ {
[Fact] [Fact]
public async Task Messages_are_exported_correctly() public async Task I_can_export_a_channel_in_the_CSV_format()
{ {
// Act // Act
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases); var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
// Assert // Assert
document.Should().ContainAll( document.Should().ContainAll(
"Tyrrrz#5447", "tyrrrz",
"Hello world", "Hello world",
"Goodbye world", "Goodbye world",
"Foo bar", "Foo bar",
@@ -17,7 +17,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class DateRangeSpecs public class DateRangeSpecs
{ {
[Fact] [Fact]
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date() public async Task I_can_filter_the_export_to_only_include_messages_sent_after_the_specified_date()
{ {
// Arrange // Arrange
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero); var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
@@ -61,7 +61,7 @@ public class DateRangeSpecs
} }
[Fact] [Fact]
public async Task Messages_filtered_before_specific_date_only_include_messages_sent_before_that_date() public async Task I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date()
{ {
// Arrange // Arrange
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero); var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
@@ -103,7 +103,7 @@ public class DateRangeSpecs
} }
[Fact] [Fact]
public async Task Messages_filtered_between_specific_dates_only_include_messages_sent_between_those_dates() public async Task I_can_filter_the_export_to_only_include_messages_sent_between_the_specified_dates()
{ {
// Arrange // Arrange
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero); var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
@@ -16,7 +16,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class FilterSpecs public class FilterSpecs
{ {
[Fact] [Fact]
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text() public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_text()
{ {
// Arrange // Arrange
using var file = TempFile.Create(); using var file = TempFile.Create();
@@ -42,7 +42,7 @@ public class FilterSpecs
} }
[Fact] [Fact]
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author() public async Task I_can_filter_the_export_to_only_include_messages_that_were_sent_by_the_specified_author()
{ {
// Arrange // Arrange
using var file = TempFile.Create(); using var file = TempFile.Create();
@@ -64,11 +64,11 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("author").GetProperty("name").GetString()) .Select(j => j.GetProperty("author").GetProperty("name").GetString())
.Should() .Should()
.AllBe("Tyrrrz"); .AllBe("tyrrrz");
} }
[Fact] [Fact]
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content() public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_content()
{ {
// Arrange // Arrange
using var file = TempFile.Create(); using var file = TempFile.Create();
@@ -94,7 +94,7 @@ public class FilterSpecs
} }
[Fact] [Fact]
public async Task Messages_filtered_by_pin_only_include_messages_that_have_been_pinned() public async Task I_can_filter_the_export_to_only_include_messages_that_have_been_pinned()
{ {
// Arrange // Arrange
using var file = TempFile.Create(); using var file = TempFile.Create();
@@ -120,7 +120,7 @@ public class FilterSpecs
} }
[Fact] [Fact]
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention() public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_mention()
{ {
// Arrange // Arrange
using var file = TempFile.Create(); using var file = TempFile.Create();
@@ -11,7 +11,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlAttachmentSpecs public class HtmlAttachmentSpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_generic_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -36,7 +36,7 @@ public class HtmlAttachmentSpecs
} }
[Fact] [Fact]
public async Task Message_with_an_image_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_attachment()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -57,7 +57,7 @@ public class HtmlAttachmentSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_video_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_attachment()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333 // https://github.com/Tyrrrz/DiscordChatExporter/issues/333
@@ -77,7 +77,7 @@ public class HtmlAttachmentSpecs
} }
[Fact] [Fact]
public async Task Message_with_an_audio_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333 // https://github.com/Tyrrrz/DiscordChatExporter/issues/333
@@ -2,7 +2,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@@ -11,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlContentSpecs public class HtmlContentSpecs
{ {
[Fact] [Fact]
public async Task Messages_are_exported_correctly() public async Task I_can_export_a_channel_in_the_HTML_format()
{ {
// Act // Act
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases); var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
@@ -39,21 +38,4 @@ public class HtmlContentSpecs
"Yeet" "Yeet"
); );
} }
[Fact]
public async Task Messages_cross_posted_from_other_guilds_are_rendered_with_the_server_tag()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/633
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.ReplyTestCases,
Snowflake.Parse("1072165330853576876")
);
// Assert
message.Text().Should().Contain("This is a test message from an announcement channel on another server");
message.Text().Should().Contain("SERVER");
message.QuerySelector(".chatlog__reply-link").Should().BeNull();
}
} }
@@ -3,6 +3,7 @@ using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@@ -11,7 +12,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlEmbedSpecs public class HtmlEmbedSpecs
{ {
[Fact] [Fact]
public async Task Message_with_an_embed_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_rich_embed()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -32,7 +33,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_with_an_image_link_is_rendered_with_an_image_embed() public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_embed()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/537 // https://github.com/Tyrrrz/DiscordChatExporter/issues/537
@@ -46,13 +47,14 @@ public class HtmlEmbedSpecs
message message
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("i.redd.it/f8w05ja8s4e61.png") ?? false) .WhereNotNull()
.Where(s => s.EndsWith("f8w05ja8s4e61.png"))
.Should() .Should()
.ContainSingle(); .ContainSingle();
} }
[Fact] [Fact]
public async Task Message_with_an_image_link_and_nothing_else_is_rendered_without_text_content() public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_embed_and_the_text_is_hidden_if_it_only_contains_the_image_link()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682 // https://github.com/Tyrrrz/DiscordChatExporter/issues/682
@@ -68,7 +70,26 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_GIFV_link_is_rendered_with_a_video_embed() public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_embed()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1083751036596002856")
);
// Assert
message
.QuerySelectorAll("source")
.Select(e => e.GetAttribute("src"))
.WhereNotNull()
.Where(s => s.EndsWith("i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4"))
.Should()
.ContainSingle();
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -80,13 +101,14 @@ public class HtmlEmbedSpecs
message message
.QuerySelectorAll("source") .QuerySelectorAll("source")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("media.tenor.com/DDAJeW6BQKkAAAPo/tooncasm-test-copy.mp4") ?? false) .WhereNotNull()
.Where(s => s.EndsWith("tooncasm-test-copy.mp4"))
.Should() .Should()
.ContainSingle(); .ContainSingle();
} }
[Fact] [Fact]
public async Task Message_with_a_GIFV_link_and_nothing_else_is_rendered_without_text_content() public async Task I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed_and_the_text_is_hidden_if_it_only_contains_the_video_link()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -100,7 +122,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_Spotify_track_link_is_rendered_with_a_track_embed() public async Task I_can_export_a_channel_that_contains_a_message_with_a_Spotify_track_embed()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/657 // https://github.com/Tyrrrz/DiscordChatExporter/issues/657
@@ -116,7 +138,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_YouTube_video_link_is_rendered_with_a_video_embed() public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/570 // https://github.com/Tyrrrz/DiscordChatExporter/issues/570
@@ -132,7 +154,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_Twitter_post_link_with_multiple_images_is_rendered_as_a_single_embed() public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitter_post_embed_that_includes_multiple_images()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695 // https://github.com/Tyrrrz/DiscordChatExporter/issues/695
@@ -158,7 +180,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_guild_invite_link_is_rendered_with_a_widget() public async Task I_can_export_a_channel_that_contains_a_message_with_a_guild_invite()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/649 // https://github.com/Tyrrrz/DiscordChatExporter/issues/649
@@ -15,7 +15,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlGroupingSpecs public class HtmlGroupingSpecs
{ {
[Fact] [Fact]
public async Task Messages_are_grouped_correctly() public async Task I_can_export_a_channel_and_the_messages_are_grouped_according_to_their_author_and_timestamps()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/152 // https://github.com/Tyrrrz/DiscordChatExporter/issues/152
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlMarkdownSpecs public class HtmlMarkdownSpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -36,7 +36,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_short_time_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_format()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -60,7 +60,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_long_time_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_format()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -84,7 +84,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_short_date_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_date_format()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -108,7 +108,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_long_date_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_date_format()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -132,7 +132,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_full_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_format()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -156,7 +156,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_full_long_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_long_format()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -180,7 +180,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_relative_timestamp_is_rendered_as_the_default_timestamp() public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_relative_format()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -204,7 +204,7 @@ public class HtmlMarkdownSpecs
} }
[Fact] [Fact]
public async Task Message_with_an_invalid_timestamp_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_an_invalid_timestamp_marker()
{ {
// Date formatting code relies on the local time zone, so we need to set it to a fixed value // Date formatting code relies on the local time zone, so we need to set it to a fixed value
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlMentionSpecs public class HtmlMentionSpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_user_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_user_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -20,11 +20,11 @@ public class HtmlMentionSpecs
// Assert // Assert
message.Text().Should().Contain("User mention: @Tyrrrz"); message.Text().Should().Contain("User mention: @Tyrrrz");
message.InnerHtml.Should().Contain("Tyrrrz#5447"); message.InnerHtml.Should().Contain("tyrrrz");
} }
[Fact] [Fact]
public async Task Message_with_a_text_channel_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -37,7 +37,7 @@ public class HtmlMentionSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_voice_channel_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -50,7 +50,7 @@ public class HtmlMentionSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_role_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_role_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlReplySpecs public class HtmlReplySpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_reply_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_another_message()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -24,7 +24,7 @@ public class HtmlReplySpecs
} }
[Fact] [Fact]
public async Task Message_with_a_reply_to_a_deleted_message_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_a_deleted_message()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/645 // https://github.com/Tyrrrz/DiscordChatExporter/issues/645
@@ -42,7 +42,7 @@ public class HtmlReplySpecs
} }
[Fact] [Fact]
public async Task Message_with_a_reply_to_an_empty_message_with_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_empty_message_with_an_attachment()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/634 // https://github.com/Tyrrrz/DiscordChatExporter/issues/634
@@ -58,7 +58,7 @@ public class HtmlReplySpecs
} }
[Fact] [Fact]
public async Task Message_with_a_reply_to_an_interaction_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_interaction()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/569 // https://github.com/Tyrrrz/DiscordChatExporter/issues/569
@@ -71,4 +71,21 @@ public class HtmlReplySpecs
// Assert // Assert
message.Text().Should().Contain("used /poll"); message.Text().Should().Contain("used /poll");
} }
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_cross_posted_from_another_guild()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/633
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.ReplyTestCases,
Snowflake.Parse("1072165330853576876")
);
// Assert
message.Text().Should().Contain("This is a test message from an announcement channel on another server");
message.Text().Should().Contain("SERVER");
message.QuerySelector(".chatlog__reply-link").Should().BeNull();
}
} }
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlStickerSpecs public class HtmlStickerSpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -19,11 +19,11 @@ public class HtmlStickerSpecs
// Assert // Assert
var stickerUrl = message.QuerySelector("[title='rock'] img")?.GetAttribute("src"); var stickerUrl = message.QuerySelector("[title='rock'] img")?.GetAttribute("src");
stickerUrl.Should().Be("https://discord.com/stickers/904215665597120572.png"); stickerUrl.Should().Be("https://cdn.discordapp.com/stickers/904215665597120572.png");
} }
[Fact] [Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -33,6 +33,6 @@ public class HtmlStickerSpecs
// Assert // Assert
var stickerUrl = message.QuerySelector("[title='Yikes'] [data-source]")?.GetAttribute("data-source"); var stickerUrl = message.QuerySelector("[title='Yikes'] [data-source]")?.GetAttribute("data-source");
stickerUrl.Should().Be("https://discord.com/stickers/816087132447178774.json"); stickerUrl.Should().Be("https://cdn.discordapp.com/stickers/816087132447178774.json");
} }
} }
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonAttachmentSpecs public class JsonAttachmentSpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_generic_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -23,15 +23,16 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt" "https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
); );
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt"); attachments[0].GetProperty("fileName").GetString().Should().Be("Test.txt");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(11); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
} }
[Fact] [Fact]
public async Task Message_with_an_image_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_attachment()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -44,15 +45,16 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png" "https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
); );
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png"); attachments[0].GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(466335); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
} }
[Fact] [Fact]
public async Task Message_with_a_video_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_attachment()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -65,15 +67,16 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4" "https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
); );
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4"); attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
} }
[Fact] [Fact]
public async Task Message_with_an_audio_attachment_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -86,10 +89,11 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3" "https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
); );
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3"); attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
} }
} }
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonContentSpecs public class JsonContentSpecs
{ {
[Fact] [Fact]
public async Task Messages_are_exported_correctly() public async Task I_can_export_a_channel_in_the_JSON_format()
{ {
// Act // Act
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases); var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonEmbedSpecs public class JsonEmbedSpecs
{ {
[Fact] [Fact]
public async Task Message_with_an_embed_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_rich_embed()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonMentionSpecs public class JsonMentionSpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_user_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_user_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -30,7 +30,7 @@ public class JsonMentionSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_text_channel_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -43,7 +43,7 @@ public class JsonMentionSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_voice_channel_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -56,7 +56,7 @@ public class JsonMentionSpecs
} }
[Fact] [Fact]
public async Task Message_with_a_role_mention_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_role_mention()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonStickerSpecs public class JsonStickerSpecs
{ {
[Fact] [Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -26,12 +26,12 @@ public class JsonStickerSpecs
sticker.GetProperty("id").GetString().Should().Be("904215665597120572"); sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
sticker.GetProperty("name").GetString().Should().Be("rock"); sticker.GetProperty("name").GetString().Should().Be("rock");
sticker.GetProperty("format").GetString().Should().Be("PngAnimated"); sticker.GetProperty("format").GetString().Should().Be("Apng");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/904215665597120572.png"); sticker.GetProperty("sourceUrl").GetString().Should().Be("https://cdn.discordapp.com/stickers/904215665597120572.png");
} }
[Fact] [Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly() public async Task I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@@ -48,6 +48,6 @@ public class JsonStickerSpecs
sticker.GetProperty("id").GetString().Should().Be("816087132447178774"); sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
sticker.GetProperty("name").GetString().Should().Be("Yikes"); sticker.GetProperty("name").GetString().Should().Be("Yikes");
sticker.GetProperty("format").GetString().Should().Be("Lottie"); sticker.GetProperty("format").GetString().Should().Be("Lottie");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/816087132447178774.json"); sticker.GetProperty("sourceUrl").GetString().Should().Be("https://cdn.discordapp.com/stickers/816087132447178774.json");
} }
} }
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class PartitioningSpecs public class PartitioningSpecs
{ {
[Fact] [Fact]
public async Task Messages_partitioned_by_count_are_split_into_a_corresponding_number_of_files() public async Task I_can_export_a_channel_with_partitioning_based_on_message_count()
{ {
// Arrange // Arrange
using var dir = TempDir.Create(); using var dir = TempDir.Create();
@@ -37,7 +37,7 @@ public class PartitioningSpecs
} }
[Fact] [Fact]
public async Task Messages_partitioned_by_file_size_are_split_into_a_corresponding_number_of_files() public async Task I_can_export_a_channel_with_partitioning_based_on_file_size()
{ {
// Arrange // Arrange
using var dir = TempDir.Create(); using var dir = TempDir.Create();
@@ -8,14 +8,14 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class PlainTextContentSpecs public class PlainTextContentSpecs
{ {
[Fact] [Fact]
public async Task Messages_are_exported_correctly() public async Task I_can_export_a_channel_in_the_TXT_format()
{ {
// Act // Act
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases); var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
// Assert // Assert
document.Should().ContainAll( document.Should().ContainAll(
"Tyrrrz#5447", "tyrrrz",
"Hello world", "Hello world",
"Goodbye world", "Goodbye world",
"Foo bar", "Foo bar",
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class SelfContainedSpecs public class SelfContainedSpecs
{ {
[Fact] [Fact]
public async Task Messages_in_self_contained_export_only_reference_local_file_resources() public async Task I_can_export_a_channel_and_download_all_referenced_assets()
{ {
// Arrange // Arrange
using var dir = TempDir.Create(); using var dir = TempDir.Create();
+29 -28
View File
@@ -1,44 +1,45 @@
# -- Build # -- Build
FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build # Specify the platform here so that we pull the SDK image matching the host platform,
# instead of the target platform specified during build by the `--platform` option.
# Use the .NET 8.0 preview because the `--arch` option is only available in that version.
# https://github.com/dotnet/dotnet-docker/issues/4388#issuecomment-1459038566
# TODO: switch images to Alpine once .NET 8.0 is released.
# Currently, the correct preview version is only available on Debian.
# https://github.com/dotnet/dotnet-docker/blob/main/samples/selecting-tags.md
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-preview AS build
# Expose the target architecture set by the `docker build --platform` option, so that
# we can build the assembly for the correct platform.
ARG TARGETARCH
WORKDIR /build WORKDIR /build
COPY favicon.ico ./ COPY favicon.ico .
COPY NuGet.config ./ COPY NuGet.config .
COPY Directory.Build.props ./ COPY Directory.Build.props .
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core COPY DiscordChatExporter.Core DiscordChatExporter.Core
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
# Publish a self-contained assembly so we can use a slimmer runtime image
RUN dotnet publish DiscordChatExporter.Cli \ RUN dotnet publish DiscordChatExporter.Cli \
--configuration Release \
--self-contained \ --self-contained \
--use-current-runtime \ --use-current-runtime \
--configuration Release \ --arch $TARGETARCH \
--output ./publish --output publish/
# -- Run # -- Run
FROM mcr.microsoft.com/dotnet/runtime-deps:7.0-alpine # Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:7.0 AS run
# Alpine dotnet image doesn't include timezone data, which is needed # Create a non-root user to run the app, so that the output files can be accessed by the host
# for certain date/time operations.
RUN apk add --no-cache tzdata
# Create a non-root user to run the app, so that the output files
# can be accessed by the host.
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851 # https://github.com/Tyrrrz/DiscordChatExporter/issues/851
RUN adduser \ RUN adduser --disabled-password --no-create-home dce
--disabled-password \
--no-create-home \
dce
USER dce USER dce
COPY --from=build /build/publish /opt/dce # This directory is exposed to the user for mounting purposes, so it's important that it always
# stays the same for backwards compatibility.
# Need to keep this as /out for backwards compatibility with documentation.
# A lot of people have this directory mounted in their scripts files, so
# changing it would break existing workflows.
WORKDIR /out WORKDIR /out
# Add the app directory to PATH so that it's easier to debug using a shell COPY --from=build /build/publish /opt/dce
ENV PATH="$PATH:/opt/dce" ENTRYPOINT ["/opt/dce/DiscordChatExporter.Cli"]
ENTRYPOINT ["DiscordChatExporter.Cli"]
@@ -7,7 +7,7 @@ using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class TokenCommandBase : ICommand public abstract class DiscordCommandBase : ICommand
{ {
[CommandOption( [CommandOption(
"token", "token",
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
@@ -20,7 +21,7 @@ using Gress;
namespace DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class ExportCommandBase : TokenCommandBase public abstract class ExportCommandBase : DiscordCommandBase
{ {
private readonly string _outputPath = Directory.GetCurrentDirectory(); private readonly string _outputPath = Directory.GetCurrentDirectory();
@@ -29,8 +30,8 @@ public abstract class ExportCommandBase : TokenCommandBase
'o', 'o',
Description = Description =
"Output file or directory path. " + "Output file or directory path. " +
"If a directory is specified, file names will be generated automatically based on the channel names and other parameters. " + "Directory path must end with a slash to avoid ambiguity. " +
"Directory path should end with a slash to avoid ambiguity. " + "If a directory is specified, file names will be generated automatically. " +
"Supports template tokens, see the documentation for more info." "Supports template tokens, see the documentation for more info."
)] )]
public string OutputPath public string OutputPath
@@ -64,13 +65,16 @@ public abstract class ExportCommandBase : TokenCommandBase
"partition", "partition",
'p', 'p',
Description = Description =
"Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')." "Split the output into partitions, each limited to the specified " +
"number of messages (e.g. '100') or file size (e.g. '10mb')."
)] )]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null; public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption( [CommandOption(
"filter", "filter",
Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." Description =
"Only include messages that satisfy this filter. " +
"See the documentation for more info."
)] )]
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null; public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
@@ -98,6 +102,22 @@ public abstract class ExportCommandBase : TokenCommandBase
)] )]
public bool ShouldReuseAssets { get; init; } public bool ShouldReuseAssets { get; init; }
private readonly string? _assetsDirPath;
[CommandOption(
"media-dir",
Description =
"Download assets to this directory. " +
"If not specified, the asset directory path will be derived from the output path."
)]
public string? AssetsDirPath
{
get => _assetsDirPath;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
}
[CommandOption( [CommandOption(
"dateformat", "dateformat",
Description = "Format used when writing dates." Description = "Format used when writing dates."
@@ -106,7 +126,10 @@ public abstract class ExportCommandBase : TokenCommandBase
[CommandOption( [CommandOption(
"fuck-russia", "fuck-russia",
Description = "Don't print the Support Ukraine message to the console." EnvironmentVariable = "FUCK_RUSSIA",
Description = "Don't print the Support Ukraine message to the console.",
// Use a converter to accept '1' as 'true' to reuse the existing environment variable
Converter = typeof(TruthyBooleanBindingConverter)
)] )]
public bool IsUkraineSupportMessageDisabled { get; init; } public bool IsUkraineSupportMessageDisabled { get; init; }
@@ -115,7 +138,7 @@ public abstract class ExportCommandBase : TokenCommandBase
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
// Reuse assets option should only be used when the download assets option is set. // Asset reuse can only be enabled if the download assets option is set
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425 // https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseAssets && !ShouldDownloadAssets) if (ShouldReuseAssets && !ShouldDownloadAssets)
{ {
@@ -124,8 +147,16 @@ public abstract class ExportCommandBase : TokenCommandBase
); );
} }
// Make sure the user does not try to export all channels into a single file. // Assets directory can only be specified if the download assets option is set
// Output path must either be a directory, or contain template tokens. if (!string.IsNullOrWhiteSpace(AssetsDirPath) && !ShouldDownloadAssets)
{
throw new CommandException(
"Option --media-dir cannot be used without --media."
);
}
// Make sure the user does not try to export multiple channels into one file.
// Output path must either be a directory or contain template tokens for this to work.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799 // https://github.com/Tyrrrz/DiscordChatExporter/issues/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917 // https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath = var isValidOutputPath =
@@ -144,10 +175,10 @@ public abstract class ExportCommandBase : TokenCommandBase
); );
} }
// Export
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var errors = new ConcurrentDictionary<Channel, string>(); var errors = new ConcurrentDictionary<Channel, string>();
// Export
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)..."); await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
await console.CreateProgressTicker().StartAsync(async progressContext => await console.CreateProgressTicker().StartAsync(async progressContext =>
{ {
@@ -163,7 +194,7 @@ public abstract class ExportCommandBase : TokenCommandBase
try try
{ {
await progressContext.StartTaskAsync( await progressContext.StartTaskAsync(
$"{channel.Category.Name} / {channel.Name}", $"{channel.Category} / {channel.Name}",
async progress => async progress =>
{ {
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken); var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
@@ -172,6 +203,7 @@ public abstract class ExportCommandBase : TokenCommandBase
guild, guild,
channel, channel,
OutputPath, OutputPath,
AssetsDirPath,
ExportFormat, ExportFormat,
After, After,
Before, Before,
@@ -199,7 +231,7 @@ public abstract class ExportCommandBase : TokenCommandBase
); );
}); });
// Print result // Print the result
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
{ {
await console.Output.WriteLineAsync( await console.Output.WriteLineAsync(
@@ -214,28 +246,26 @@ public abstract class ExportCommandBase : TokenCommandBase
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console.Output.WriteLineAsync( await console.Error.WriteLineAsync(
$"Failed to export {errors.Count} channel(s):" $"Failed to export {errors.Count} channel(s):"
); );
} }
foreach (var (channel, error) in errors) foreach (var (channel, error) in errors)
{ {
await console.Output.WriteAsync($"{channel.Category.Name} / {channel.Name}: "); await console.Error.WriteAsync($"{channel.Category} / {channel.Name}: ");
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
await console.Output.WriteLineAsync(error); await console.Error.WriteLineAsync(error);
} }
await console.Output.WriteLineAsync(); await console.Error.WriteLineAsync();
} }
// Fail the command only if ALL channels failed to export. // Fail the command only if ALL channels failed to export.
// Having some of the channels fail to export is expected. // If only some channels failed to export, it's okay.
if (errors.Count >= channels.Count) if (errors.Count >= channels.Count)
{
throw new CommandException("Export failed."); throw new CommandException("Export failed.");
}
} }
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Snowflake> channelIds) protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
@@ -245,7 +275,7 @@ public abstract class ExportCommandBase : TokenCommandBase
await console.Output.WriteLineAsync("Resolving channel(s)..."); await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>(); var channels = new List<Channel>();
var guildChannelMap = new Dictionary<Snowflake, IReadOnlyList<Channel>>(); var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in channelIds) foreach (var channelId in channelIds)
{ {
@@ -255,17 +285,17 @@ public abstract class ExportCommandBase : TokenCommandBase
if (channel.Kind == ChannelKind.GuildCategory) if (channel.Kind == ChannelKind.GuildCategory)
{ {
var guildChannels = var guildChannels =
guildChannelMap.GetValueOrDefault(channel.GuildId) ?? channelsByGuild.GetValueOrDefault(channel.GuildId) ??
await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken); await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels) foreach (var guildChannel in guildChannels)
{ {
if (guildChannel.Category.Id == channel.Id) if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel); channels.Add(guildChannel);
} }
// Cache the guild channels to avoid redundant work // Cache the guild channels to avoid redundant work
guildChannelMap[channel.GuildId] = guildChannels; channelsByGuild[channel.GuildId] = guildChannels;
} }
else else
{ {
@@ -0,0 +1,25 @@
using System.Globalization;
using CliFx.Extensibility;
namespace DiscordChatExporter.Cli.Commands.Converters;
internal class TruthyBooleanBindingConverter : BindingConverter<bool>
{
public override bool Convert(string? rawValue)
{
// Null is still considered true, to match the base behavior
if (rawValue is null)
return true;
if (string.IsNullOrWhiteSpace(rawValue))
return false;
if (bool.TryParse(rawValue, out var boolValue))
return boolValue;
if (int.TryParse(rawValue, CultureInfo.InvariantCulture, out var intValue) && intValue == 0)
return false;
return true;
}
}
@@ -1,20 +1,46 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO.Compression;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("exportall", Description = "Export all accessible channels.")] [Command("exportall", Description = "Exports all accessible channels.")]
public class ExportAllCommand : ExportCommandBase public class ExportAllCommand : ExportCommandBase
{ {
[CommandOption( [CommandOption(
"include-dm", "include-dm",
Description = "Include direct message channels." Description = "Include direct message channels."
)] )]
public bool IncludeDirectMessages { get; init; } = true; public bool IncludeDirectChannels { get; init; } = true;
[CommandOption(
"include-guilds",
Description = "Include guild channels."
)]
public bool IncludeGuildChannels { get; init; } = true;
[CommandOption(
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"data-package",
Description =
"Path to the personal data package (ZIP file) requested from Discord. " +
"If provided, only channels referenced in the dump will be exported."
)]
public string? DataPackageFilePath { get; init; }
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -23,16 +49,62 @@ public class ExportAllCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>(); var channels = new List<Channel>();
await console.Output.WriteLineAsync("Fetching channels..."); // Pull from the API
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken)) if (string.IsNullOrWhiteSpace(DataPackageFilePath))
{ {
// Skip DMs if instructed to await console.Output.WriteLineAsync("Fetching channels...");
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
continue;
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken)) await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
channels.Add(channel); {
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
{
channels.Add(channel);
}
}
} }
// Pull from the data package
else
{
await console.Output.WriteLineAsync("Extracting channels...");
using var archive = ZipFile.OpenRead(DataPackageFilePath);
var entry = archive.GetEntry("messages/index.json");
if (entry is null)
throw new CommandException("Could not find channel index inside the data package.");
await using var stream = entry.Open();
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
foreach (var property in document.RootElement.EnumerateObjectOrEmpty())
{
var channelId = Snowflake.Parse(property.Name);
var channelName = property.Value.GetString();
// Null items refer to deleted channels
if (channelName is null)
continue;
await console.Output.WriteLineAsync($"Fetching channel '{channelName}' ({channelId})...");
try
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
channels.Add(channel);
}
catch (DiscordChatExporterException)
{
await console.Error.WriteLineAsync($"Channel '{channelName}' ({channelId}) is inaccessible.");
}
}
}
// Filter out unwanted channels
if (!IncludeDirectChannels)
channels.RemoveAll(c => c.Kind.IsDirect());
if (!IncludeGuildChannels)
channels.RemoveAll(c => c.Kind.IsGuild());
if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.Kind.IsVoice());
await base.ExecuteAsync(console, channels); await base.ExecuteAsync(console, channels);
} }
@@ -7,14 +7,16 @@ using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("export", Description = "Export one or multiple channels.")] [Command("export", Description = "Exports one or multiple channels.")]
public class ExportChannelsCommand : ExportCommandBase public class ExportChannelsCommand : ExportCommandBase
{ {
// TODO: change this to plural (breaking change) // TODO: change this to plural (breaking change)
[CommandOption( [CommandOption(
"channel", "channel",
'c', 'c',
Description = "Channel ID(s). If provided with a category ID, all channels in that category will be exported." Description =
"Channel ID(s). " +
"If provided with category ID(s), all channels inside those categories will be exported."
)] )]
public required IReadOnlyList<Snowflake> ChannelIds { get; init; } public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
@@ -1,5 +1,4 @@
using System.Linq; using System.Threading.Tasks;
using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
@@ -8,7 +7,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("exportdm", Description = "Export all direct message channels.")] [Command("exportdm", Description = "Exports all direct message channels.")]
public class ExportDirectMessagesCommand : ExportCommandBase public class ExportDirectMessagesCommand : ExportCommandBase
{ {
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
@@ -18,10 +17,7 @@ public class ExportDirectMessagesCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels..."); await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory)
.ToArray();
await base.ExecuteAsync(console, channels); await base.ExecuteAsync(console, channels);
} }
@@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Export all channels within specified guild.")] [Command("exportguild", Description = "Exports all channels within the specified guild.")]
public class ExportGuildCommand : ExportCommandBase public class ExportGuildCommand : ExportCommandBase
{ {
[CommandOption( [CommandOption(
@@ -19,6 +19,12 @@ public class ExportGuildCommand : ExportCommandBase
)] )]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption(
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
@@ -29,6 +35,7 @@ public class ExportGuildCommand : ExportCommandBase
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken)) var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory) .Where(c => c.Kind != ChannelKind.GuildCategory)
.Where(c => IncludeVoiceChannels || !c.Kind.IsVoice())
.ToArray(); .ToArray();
await base.ExecuteAsync(console, channels); await base.ExecuteAsync(console, channels);
@@ -11,7 +11,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("channels", Description = "Get the list of channels in a guild.")] [Command("channels", Description = "Get the list of channels in a guild.")]
public class GetChannelsCommand : TokenCommandBase public class GetChannelsCommand : DiscordCommandBase
{ {
[CommandOption( [CommandOption(
"guild", "guild",
@@ -20,21 +20,36 @@ public class GetChannelsCommand : TokenCommandBase
)] )]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption(
"include-threads",
Description = "Include threads in the output."
)]
public bool IncludeThreads { get; init; }
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken)) var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory) .Where(c => c.Kind != ChannelKind.GuildCategory)
.OrderBy(c => c.Category.Position) .OrderBy(c => c.Parent?.Position)
.ThenBy(c => c.Name) .ThenBy(c => c.Name)
.ToArray(); .ToArray();
var channelIdMaxLength = channels
.Select(c => c.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
var threads = IncludeThreads
? (await Discord.GetGuildThreadsAsync(GuildId, cancellationToken)).OrderBy(c => c.Name).ToArray()
: Array.Empty<Channel>();
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
await console.Output.WriteAsync( await console.Output.WriteAsync(
channel.Id.ToString().PadRight(18, ' ') channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
); );
// Separator // Separator
@@ -43,7 +58,40 @@ public class GetChannelsCommand : TokenCommandBase
// Channel category / name // Channel category / name
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}"); await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}");
var channelThreads = threads.Where(t => t.Parent?.Id == channel.Id).ToArray();
var channelThreadIdMaxLength = channelThreads
.Select(t => t.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var channelThread in channelThreads)
{
// Indent
await console.Output.WriteAsync(" * ");
// Thread ID
await console.Output.WriteAsync(
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
);
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Thread name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteAsync($"Thread / {channelThread.Name}");
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Thread status
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(channelThread.IsActive ? "Active" : "Archived");
}
} }
} }
} }
@@ -9,8 +9,8 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("dm", Description = "Get the list of direct message channels.")] [Command("dm", Description = "Gets the list of all direct message channels.")]
public class GetDirectMessageChannelsCommand : TokenCommandBase public class GetDirectChannelsCommand : DiscordCommandBase
{ {
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -22,11 +22,16 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
.ThenBy(c => c.Name) .ThenBy(c => c.Name)
.ToArray(); .ToArray();
var channelIdMaxLength = channels
.Select(c => c.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
await console.Output.WriteAsync( await console.Output.WriteAsync(
channel.Id.ToString().PadRight(18, ' ') channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
); );
// Separator // Separator
@@ -35,7 +40,7 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
// Channel category / name // Channel category / name
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}"); await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}");
} }
} }
} }
@@ -9,8 +9,8 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("guilds", Description = "Get the list of accessible guilds.")] [Command("guilds", Description = "Gets the list of accessible guilds.")]
public class GetGuildsCommand : TokenCommandBase public class GetGuildsCommand : DiscordCommandBase
{ {
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -19,13 +19,19 @@ public class GetGuildsCommand : TokenCommandBase
var guilds = (await Discord.GetUserGuildsAsync(cancellationToken)) var guilds = (await Discord.GetUserGuildsAsync(cancellationToken))
// Show direct messages first // Show direct messages first
.OrderByDescending(g => g.Id == Guild.DirectMessages.Id) .OrderByDescending(g => g.Id == Guild.DirectMessages.Id)
.ThenBy(g => g.Name); .ThenBy(g => g.Name)
.ToArray();
var guildIdMaxLength = guilds
.Select(g => g.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var guild in guilds) foreach (var guild in guilds)
{ {
// Guild ID // Guild ID
await console.Output.WriteAsync( await console.Output.WriteAsync(
guild.Id.ToString().PadRight(18, ' ') guild.Id.ToString().PadRight(guildIdMaxLength, ' ')
); );
// Separator // Separator
@@ -6,7 +6,7 @@ using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("guide", Description = "Explains how to obtain token, guild or channel ID.")] [Command("guide", Description = "Explains how to obtain the token, guild or channel ID.")]
public class GuideCommand : ICommand public class GuideCommand : ICommand
{ {
public ValueTask ExecuteAsync(IConsole console) public ValueTask ExecuteAsync(IConsole console)
@@ -41,25 +41,13 @@ public class GuideCommand : ICommand
// Guild or channel ID // Guild or channel ID
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get guild ID or guild channel ID:"); console.Output.WriteLine("To get guild ID or channel ID:");
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open Settings"); console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Advanced section"); console.Output.WriteLine(" 3. Go to Advanced section");
console.Output.WriteLine(" 4. Enable Developer Mode"); console.Output.WriteLine(" 4. Enable Developer Mode");
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID"); console.Output.WriteLine(" 5. Right-click on the desired guild or channel and click Copy Server ID or Copy Channel ID");
console.Output.WriteLine();
// Direct message channel ID
using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get direct message channel ID:");
console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open the desired direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 4. Navigate to the Console tab");
console.Output.WriteLine(" 5. Type \"window.location.href\" and press Enter");
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
console.Output.WriteLine(); console.Output.WriteLine();
// Docs link // Docs link
@@ -6,11 +6,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.1" /> <PackageReference Include="CliFx" Version="2.3.4" />
<PackageReference Include="FuckRussia" Version="1.0.1" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="Spectre.Console" Version="0.46.0" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.4.0" PrivateAssets="all" /> <PackageReference Include="Spectre.Console" Version="0.47.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -2,7 +2,6 @@
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@@ -20,11 +19,26 @@ public partial record Attachment(
{ {
public string FileExtension => Path.GetExtension(FileName); public string FileExtension => Path.GetExtension(FileName);
public bool IsImage => FileFormat.IsImage(FileExtension); public bool IsImage =>
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".jpeg", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".png", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".gif", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".bmp", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".webp", StringComparison.OrdinalIgnoreCase);
public bool IsVideo => FileFormat.IsVideo(FileExtension); public bool IsVideo =>
string.Equals(FileExtension, ".gifv", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".mp4", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".webm", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".mov", StringComparison.OrdinalIgnoreCase);
public bool IsAudio => FileFormat.IsAudio(FileExtension); public bool IsAudio =>
string.Equals(FileExtension, ".mp3", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".wav", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".ogg", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase) ||
string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal); public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
} }
@@ -1,5 +1,4 @@
using System; using System.Linq;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -12,45 +11,44 @@ public partial record Channel(
Snowflake Id, Snowflake Id,
ChannelKind Kind, ChannelKind Kind,
Snowflake GuildId, Snowflake GuildId,
ChannelCategory Category, Channel? Parent,
string Name, string Name,
int? Position, int? Position,
string? IconUrl, string? IconUrl,
string? Topic, string? Topic,
bool IsActive,
Snowflake? LastMessageId) : IHasId Snowflake? LastMessageId) : IHasId
{ {
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice; // Used for visual backwards-compatibility with old exports, where
// channels without a parent (i.e. mostly DM channels) had a fallback
// category created for them.
public string Category => Parent?.Name ?? Kind switch
{
ChannelKind.GuildCategory => "Category",
ChannelKind.GuildTextChat => "Text",
ChannelKind.DirectTextChat => "Private",
ChannelKind.DirectGroupTextChat => "Group",
ChannelKind.GuildPrivateThread => "Private Thread",
ChannelKind.GuildPublicThread => "Public Thread",
ChannelKind.GuildNews => "News",
ChannelKind.GuildNewsThread => "News Thread",
_ => "Default"
};
// Only needed for WPF data binding. Don't use anywhere else.
public bool IsVoice => Kind.IsVoice();
} }
public partial record Channel public partial record Channel
{ {
private static ChannelCategory GetFallbackCategory(ChannelKind channelKind) => new( public static Channel Parse(JsonElement json, Channel? parent = null, int? positionHint = null)
Snowflake.Zero,
channelKind switch
{
ChannelKind.GuildTextChat => "Text",
ChannelKind.DirectTextChat => "Private",
ChannelKind.DirectGroupTextChat => "Group",
ChannelKind.GuildNews => "News",
_ => "Default"
},
null
);
private static string GetIconUrl(Snowflake id, string iconHash)
{
var extension = iconHash.StartsWith("a_", StringComparison.Ordinal)
? "gif"
: "png";
return $"https://cdn.discordapp.com/channel-icons/{id}/{iconHash}.{extension}";
}
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var kind = (ChannelKind)json.GetProperty("type").GetInt32(); var kind = (ChannelKind)json.GetProperty("type").GetInt32();
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
var guildId =
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ??
Guild.DirectMessages.Id;
var name = var name =
// Guild channel // Guild channel
@@ -60,7 +58,7 @@ public partial record Channel
json.GetPropertyOrNull("recipients")? json.GetPropertyOrNull("recipients")?
.EnumerateArrayOrNull()? .EnumerateArrayOrNull()?
.Select(User.Parse) .Select(User.Parse)
.Select(u => u.Name) .Select(u => u.DisplayName)
.Pipe(s => string.Join(", ", s)) ?? .Pipe(s => string.Join(", ", s)) ??
// Fallback // Fallback
@@ -70,11 +68,19 @@ public partial record Channel
positionHint ?? positionHint ??
json.GetPropertyOrNull("position")?.GetInt32OrNull(); json.GetPropertyOrNull("position")?.GetInt32OrNull();
// Only available on group DMs // Icons can only be set for group DM channels
var iconUrl = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)); var iconUrl = json
.GetPropertyOrNull("icon")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull(); var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
var isActive = !json
.GetPropertyOrNull("thread_metadata")?
.GetPropertyOrNull("archived")?
.GetBooleanOrNull() ?? true;
var lastMessageId = json var lastMessageId = json
.GetPropertyOrNull("last_message_id")? .GetPropertyOrNull("last_message_id")?
.GetNonWhiteSpaceStringOrNull()? .GetNonWhiteSpaceStringOrNull()?
@@ -83,12 +89,13 @@ public partial record Channel
return new Channel( return new Channel(
id, id,
kind, kind,
guildId ?? Guild.DirectMessages.Id, guildId,
category ?? GetFallbackCategory(kind), parent,
name, name,
position, position,
iconUrl, iconUrl,
topic, topic,
isActive,
lastMessageId lastMessageId
); );
} }
@@ -1,26 +0,0 @@
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
{
public static ChannelCategory Unknown { get; } = new(Snowflake.Zero, "<unknown category>", 0);
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
{
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name =
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
id.ToString();
var position =
positionHint ??
json.GetPropertyOrNull("position")?.GetInt32OrNull();
return new ChannelCategory(id, name, position);
}
}
@@ -1,7 +1,6 @@
namespace DiscordChatExporter.Core.Discord.Data; namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types // 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 ChannelKind public enum ChannelKind
{ {
GuildTextChat = 0, GuildTextChat = 0,
@@ -16,4 +15,16 @@ public enum ChannelKind
GuildStageVoice = 13, GuildStageVoice = 13,
GuildDirectory = 14, GuildDirectory = 14,
GuildForum = 15 GuildForum = 15
}
public static class ChannelKindExtensions
{
public static bool IsDirect(this ChannelKind kind) =>
kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
public static bool IsGuild(this ChannelKind kind) =>
!kind.IsDirect();
public static bool IsVoice(this ChannelKind kind) =>
kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
} }
@@ -1,12 +0,0 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Discord.Data.Common;
public class IdBasedEqualityComparer : IEqualityComparer<IHasId>
{
public static IdBasedEqualityComparer Instance { get; } = new();
public bool Equals(IHasId? x, IHasId? y) => x?.Id == y?.Id;
public int GetHashCode(IHasId obj) => obj.Id.GetHashCode();
}
@@ -0,0 +1,60 @@
using System;
using System.Linq;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Discord.Data.Common;
// https://discord.com/developers/docs/reference#image-formatting
public static class ImageCdn
{
// Standard emoji are rendered through Twemoji
public static string GetStandardEmojiUrl(string emojiName)
{
var runes = emojiName.GetRunes().ToArray();
// Variant selector rune is skipped in Twemoji IDs,
// except when the emoji also contains a zero-width joiner.
// VS = 0xfe0f; ZWJ = 0x200d.
var filteredRunes = runes.Any(r => r.Value == 0x200d)
? runes
: runes.Where(r => r.Value != 0xfe0f);
var twemojiId = string.Join(
"-",
filteredRunes.Select(r => r.Value.ToString("x"))
);
return $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{twemojiId}.svg";
}
public static string GetCustomEmojiUrl(Snowflake emojiId, bool isAnimated = false) =>
isAnimated
? $"https://cdn.discordapp.com/emojis/{emojiId}.gif"
: $"https://cdn.discordapp.com/emojis/{emojiId}.png";
public static string GetGuildIconUrl(Snowflake guildId, string iconHash, int size = 512) =>
iconHash.StartsWith("a_", StringComparison.Ordinal)
? $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.gif?size={size}"
: $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.png?size={size}";
public static string GetChannelIconUrl(Snowflake channelId, string iconHash, int size = 512) =>
iconHash.StartsWith("a_", StringComparison.Ordinal)
? $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.gif?size={size}"
: $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.png?size={size}";
public static string GetUserAvatarUrl(Snowflake userId, string avatarHash, int size = 512) =>
avatarHash.StartsWith("a_", StringComparison.Ordinal)
? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}"
: $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}";
public static string GetFallbackUserAvatarUrl(int index = 0) =>
$"https://cdn.discordapp.com/embed/avatars/{index}.png";
public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) =>
avatarHash.StartsWith("a_", StringComparison.Ordinal)
? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.gif?size={size}"
: $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.png?size={size}";
public static string GetStickerUrl(Snowflake stickerId, string format = "png") =>
$"https://cdn.discordapp.com/stickers/{stickerId}.{format}";
}
@@ -45,7 +45,7 @@ public partial record Embed
EmbedKind.Rich; EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset(); var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull();
var color = json var color = json
.GetPropertyOrNull("color")? .GetPropertyOrNull("color")?
@@ -70,7 +70,7 @@ public partial record Embed
// with this by merging related embeds at the end of the message parsing process. // with this by merging related embeds at the end of the message parsing process.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695 // https://github.com/Tyrrrz/DiscordChatExporter/issues/695
var images = var images =
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ?? json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).ToSingletonEnumerable().ToArray() ??
Array.Empty<EmbedImage>(); Array.Empty<EmbedImage>();
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse); var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
@@ -1,6 +1,4 @@
using System.Text.RegularExpressions; namespace DiscordChatExporter.Core.Discord.Data.Embeds;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record YouTubeVideoEmbedProjection(string VideoId) public partial record YouTubeVideoEmbedProjection(string VideoId)
{ {
@@ -9,37 +7,6 @@ public partial record YouTubeVideoEmbedProjection(string VideoId)
public partial record YouTubeVideoEmbedProjection public partial record YouTubeVideoEmbedProjection
{ {
// Adapted from YoutubeExplode
// https://github.com/Tyrrrz/YoutubeExplode/blob/bc700b631bd105d0be208a88116347034bdca88b/YoutubeExplode/Videos/VideoId.cs#L40-L62
private static string? TryParseVideoId(string embedUrl)
{
// Regular URL
// https://www.youtube.com/watch?v=yIVRs6YSbOM
var regularMatch = Regex.Match(embedUrl, @"youtube\..+?/watch.*?v=(.*?)(?:&|/|$)").Groups[1].Value;
if (!string.IsNullOrWhiteSpace(regularMatch))
return regularMatch;
// Short URL
// https://youtu.be/yIVRs6YSbOM
var shortMatch = Regex.Match(embedUrl, @"youtu\.be/(.*?)(?:\?|&|/|$)").Groups[1].Value;
if (!string.IsNullOrWhiteSpace(shortMatch))
return shortMatch;
// Embed URL
// https://www.youtube.com/embed/yIVRs6YSbOM
var embedMatch = Regex.Match(embedUrl, @"youtube\..+?/embed/(.*?)(?:\?|&|/|$)").Groups[1].Value;
if (!string.IsNullOrWhiteSpace(embedMatch))
return embedMatch;
// Shorts URL
// https://www.youtube.com/shorts/sKL1vjP0tIo
var shortsMatch = Regex.Match(embedUrl, @"youtube\..+?/shorts/(.*?)(?:\?|&|/|$)").Groups[1].Value;
if (!string.IsNullOrWhiteSpace(shortsMatch))
return shortsMatch;
return null;
}
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed) public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
{ {
if (embed.Kind != EmbedKind.Video) if (embed.Kind != EmbedKind.Video)
@@ -48,8 +15,8 @@ public partial record YouTubeVideoEmbedProjection
if (string.IsNullOrWhiteSpace(embed.Url)) if (string.IsNullOrWhiteSpace(embed.Url))
return null; return null;
var videoId = TryParseVideoId(embed.Url); var videoId = YoutubeExplode.Videos.VideoId.TryParse(embed.Url);
if (string.IsNullOrWhiteSpace(videoId)) if (videoId is null)
return null; return null;
return new YouTubeVideoEmbedProjection(videoId); return new YouTubeVideoEmbedProjection(videoId);
+9 -31
View File
@@ -1,6 +1,6 @@
using System; using System;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@@ -24,54 +24,32 @@ public partial record Emoji(
public partial record Emoji public partial record Emoji
{ {
private static string GetTwemojiId(string name)
{
var runes = name.GetRunes().ToArray();
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner.
// VS = 0xfe0f; ZWJ = 0x200d.
var filteredRunes = runes.Any(r => r.Value == 0x200d)
? runes
: runes.Where(r => r.Value != 0xfe0f);
return string.Join(
"-",
filteredRunes.Select(r => r.Value.ToString("x"))
);
}
private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated
? $"https://cdn.discordapp.com/emojis/{id}.gif"
: $"https://cdn.discordapp.com/emojis/{id}.png";
private static string GetImageUrl(string name) =>
$"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{GetTwemojiId(name)}.svg";
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated) public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{ {
// Custom emoji // Custom emoji
if (id is not null) if (id is not null)
return GetImageUrl(id.Value, isAnimated); return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
// Standard emoji // Standard emoji
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
return GetImageUrl(name); return ImageCdn.GetStandardEmojiUrl(name);
// Either ID or name should be set throw new InvalidOperationException("Either the emoji ID or name should be provided.");
throw new ApplicationException("Emoji has neither ID nor name set.");
} }
public static Emoji Parse(JsonElement json) public static Emoji Parse(JsonElement json)
{ {
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
// Names may be missing on custom emoji within reactions
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false; var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
var imageUrl = GetImageUrl(id, name, isAnimated); var imageUrl = GetImageUrl(id, name, isAnimated);
return new Emoji( return new Emoji(
id, id,
// Name may be missing if it's an emoji inside a reaction name,
name ?? "<unknown emoji>",
isAnimated, isAnimated,
imageUrl imageUrl
); );
+8 -17
View File
@@ -1,5 +1,4 @@
using System; using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@@ -9,32 +8,24 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/guild#guild-object // https://discord.com/developers/docs/resources/guild#guild-object
public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
{ {
// Direct messages are encapsulated within a special pseudo-guild for consistency
public static Guild DirectMessages { get; } = new( public static Guild DirectMessages { get; } = new(
Snowflake.Zero, Snowflake.Zero,
"Direct Messages", "Direct Messages",
GetDefaultIconUrl() ImageCdn.GetFallbackUserAvatarUrl()
); );
private static string GetDefaultIconUrl() =>
"https://cdn.discordapp.com/embed/avatars/0.png";
private static string GetIconUrl(Snowflake id, string iconHash)
{
var extension = iconHash.StartsWith("a_", StringComparison.Ordinal)
? "gif"
: "png";
return $"https://cdn.discordapp.com/icons/{id}/{iconHash}.{extension}";
}
public static Guild Parse(JsonElement json) public static Guild Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonNullString(); var name = json.GetProperty("name").GetNonNullString();
var iconUrl = var iconUrl =
json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)) ?? json
GetDefaultIconUrl(); .GetPropertyOrNull("icon")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl();
return new Guild(id, name, iconUrl); return new Guild(id, name, iconUrl);
} }
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/invite#invite-object // https://discord.com/developers/docs/resources/invite#invite-object
public record Invite( public record Invite(
string Code, string Code,
Guild? Guild, Guild Guild,
Channel? Channel) Channel? Channel)
{ {
public static string? TryGetCodeFromUrl(string url) => public static string? TryGetCodeFromUrl(string url) =>
@@ -17,7 +17,7 @@ public record Invite(
public static Invite Parse(JsonElement json) public static Invite Parse(JsonElement json)
{ {
var code = json.GetProperty("code").GetNonWhiteSpaceString(); var code = json.GetProperty("code").GetNonWhiteSpaceString();
var guild = json.GetPropertyOrNull("guild")?.Pipe(Guild.Parse); var guild = json.GetPropertyOrNull("guild")?.Pipe(Guild.Parse) ?? Guild.DirectMessages;
var channel = json.GetPropertyOrNull("channel")?.Pipe(c => Channel.Parse(c)); var channel = json.GetPropertyOrNull("channel")?.Pipe(c => Channel.Parse(c));
return new Invite(code, guild, channel); return new Invite(code, guild, channel);
@@ -11,7 +11,8 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/guild#guild-member-object // https://discord.com/developers/docs/resources/guild#guild-member-object
public partial record Member( public partial record Member(
User User, User User,
string Nick, string? DisplayName,
string? AvatarUrl,
IReadOnlyList<Snowflake> RoleIds) : IHasId IReadOnlyList<Snowflake> RoleIds) : IHasId
{ {
public Snowflake Id => User.Id; public Snowflake Id => User.Id;
@@ -19,10 +20,13 @@ public partial record Member(
public partial record Member public partial record Member
{ {
public static Member Parse(JsonElement json) public static Member CreateFallback(User user) =>
new(user, null, null, Array.Empty<Snowflake>());
public static Member Parse(JsonElement json, Snowflake? guildId = null)
{ {
var user = json.GetProperty("user").Pipe(User.Parse); var user = json.GetProperty("user").Pipe(User.Parse);
var nick = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull(); var displayName = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull();
var roleIds = json var roleIds = json
.GetPropertyOrNull("roles")? .GetPropertyOrNull("roles")?
@@ -31,9 +35,17 @@ public partial record Member
.Select(Snowflake.Parse) .Select(Snowflake.Parse)
.ToArray() ?? Array.Empty<Snowflake>(); .ToArray() ?? Array.Empty<Snowflake>();
var avatarUrl = guildId is not null
? json
.GetPropertyOrNull("avatar")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h))
: null;
return new Member( return new Member(
user, user,
nick ?? user.Name, displayName,
avatarUrl,
roleIds roleIds
); );
} }
@@ -108,15 +108,16 @@ public partial record Message
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var kind = (MessageKind)json.GetProperty("type").GetInt32(); var kind = (MessageKind)json.GetProperty("type").GetInt32();
var flags = (MessageFlags?)json.GetPropertyOrNull("flags")?.GetInt32() ?? MessageFlags.None; var flags = (MessageFlags?)json.GetPropertyOrNull("flags")?.GetInt32OrNull() ?? MessageFlags.None;
var author = json.GetProperty("author").Pipe(User.Parse); var author = json.GetProperty("author").Pipe(User.Parse);
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset(); var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset(); var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
var callEndedTimestamp = json var callEndedTimestamp = json
.GetPropertyOrNull("call")? .GetPropertyOrNull("call")?
.GetPropertyOrNull("ended_timestamp")? .GetPropertyOrNull("ended_timestamp")?
.GetDateTimeOffset(); .GetDateTimeOffsetOrNull();
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false; var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""; var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
@@ -17,5 +17,5 @@ public enum MessageKind
public static class MessageKindExtensions public static class MessageKindExtensions
{ {
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18; public static bool IsSystemNotification(this MessageKind kind) => (int)kind is >= 1 and <= 18;
} }
@@ -17,7 +17,7 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
var color = json var color = json
.GetPropertyOrNull("color")? .GetPropertyOrNull("color")?
.GetInt32() .GetInt32OrNull()?
.Pipe(System.Drawing.Color.FromArgb) .Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha() .ResetAlpha()
.NullIf(c => c.ToRgb() <= 0); .NullIf(c => c.ToRgb() <= 0);
@@ -1,4 +1,6 @@
using System.Text.Json; using System;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@@ -7,18 +9,19 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/sticker#sticker-resource // https://discord.com/developers/docs/resources/sticker#sticker-resource
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl) public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
{ {
private static string GetSourceUrl(Snowflake id, StickerFormat format)
{
var extension = format == StickerFormat.Lottie ? "json" : "png";
return $"https://discord.com/stickers/{id}.{extension}";
}
public static Sticker Parse(JsonElement json) public static Sticker Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonNullString(); var name = json.GetProperty("name").GetNonNullString();
var format = (StickerFormat)json.GetProperty("format_type").GetInt32(); var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
var sourceUrl = GetSourceUrl(id, format);
var sourceUrl = ImageCdn.GetStickerUrl(id, format switch
{
StickerFormat.Png => "png",
StickerFormat.Apng => "png",
StickerFormat.Lottie => "json",
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.")
});
return new Sticker(id, name, format, sourceUrl); return new Sticker(id, name, format, sourceUrl);
} }
@@ -3,6 +3,6 @@
public enum StickerFormat public enum StickerFormat
{ {
Png = 1, Png = 1,
PngAnimated = 2, Apng = 2,
Lottie = 3 Lottie = 3
} }
+31 -21
View File
@@ -1,5 +1,4 @@
using System; using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
@@ -10,40 +9,51 @@ namespace DiscordChatExporter.Core.Discord.Data;
public partial record User( public partial record User(
Snowflake Id, Snowflake Id,
bool IsBot, bool IsBot,
int Discriminator, // Remove after Discord migrates all accounts to the new system.
// With that, also remove the DiscriminatorFormatted and FullName properties.
// Replace existing calls to FullName with Name (not DisplayName).
int? Discriminator,
string Name, string Name,
string DisplayName,
string AvatarUrl) : IHasId string AvatarUrl) : IHasId
{ {
public string DiscriminatorFormatted => $"{Discriminator:0000}"; public string DiscriminatorFormatted => Discriminator is not null
? $"{Discriminator:0000}"
: "0000";
public string FullName => $"{Name}#{DiscriminatorFormatted}"; // This effectively represents the user's true identity.
// In the old system, this is formed from the username and discriminator.
// In the new system, the username is already the user's unique identifier.
public string FullName => Discriminator is not null
? $"{Name}#{DiscriminatorFormatted}"
: Name;
} }
public partial record User public partial record User
{ {
private static string GetDefaultAvatarUrl(int discriminator) =>
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
private static string GetAvatarUrl(Snowflake id, string avatarHash)
{
var extension = avatarHash.StartsWith("a_", StringComparison.Ordinal)
? "gif"
: "png";
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=512";
}
public static User Parse(JsonElement json) public static User Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false; var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
var discriminator = json
.GetPropertyOrNull("discriminator")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(int.Parse)
.NullIfDefault();
var name = json.GetProperty("username").GetNonNullString(); var name = json.GetProperty("username").GetNonNullString();
var displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name;
var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6);
var avatarUrl = var avatarUrl =
json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetAvatarUrl(id, h)) ?? json
GetDefaultAvatarUrl(discriminator); .GetPropertyOrNull("avatar")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
return new User(id, isBot, discriminator, name, avatarUrl); return new User(id, isBot, discriminator, name, displayName, avatarUrl);
} }
} }
+126 -53
View File
@@ -144,19 +144,25 @@ public class DiscordClient
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
using var response = await GetResponseAsync(url, cancellationToken); using var response = await GetResponseAsync(url, cancellationToken);
return response.IsSuccessStatusCode return response.IsSuccessStatusCode
? await response.Content.ReadAsJsonAsync(cancellationToken) ? await response.Content.ReadAsJsonAsync(cancellationToken)
: null; : null;
} }
public async ValueTask<User?> TryGetUserAsync(
Snowflake userId,
CancellationToken cancellationToken = default)
{
var response = await TryGetJsonResponseAsync($"users/{userId}", cancellationToken);
return response?.Pipe(User.Parse);
}
public async IAsyncEnumerable<Guild> GetUserGuildsAsync( public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default)
{ {
yield return Guild.DirectMessages; yield return Guild.DirectMessages;
var currentAfter = Snowflake.Zero; var currentAfter = Snowflake.Zero;
while (true) while (true)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
@@ -168,8 +174,9 @@ public class DiscordClient
var response = await GetJsonResponseAsync(url, cancellationToken); var response = await GetJsonResponseAsync(url, cancellationToken);
var isEmpty = true; var isEmpty = true;
foreach (var guild in response.EnumerateArray().Select(Guild.Parse)) foreach (var guildJson in response.EnumerateArray())
{ {
var guild = Guild.Parse(guildJson);
yield return guild; yield return guild;
currentAfter = guild.Id; currentAfter = guild.Id;
@@ -206,34 +213,114 @@ public class DiscordClient
{ {
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels", cancellationToken); var response = await GetJsonResponseAsync($"guilds/{guildId}/channels", cancellationToken);
var responseOrdered = response var channelsJson = response
.EnumerateArray() .EnumerateArray()
.OrderBy(j => j.GetProperty("position").GetInt32()) .OrderBy(j => j.GetProperty("position").GetInt32())
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse)) .ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
.ToArray(); .ToArray();
var categories = responseOrdered var parentsById = channelsJson
.Where(j => j.GetProperty("type").GetInt32() == (int) ChannelKind.GuildCategory) .Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory)
.Select((j, index) => ChannelCategory.Parse(j, index + 1)) .Select((j, i) => Channel.Parse(j, null, i + 1))
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal); .ToDictionary(j => j.Id);
// Discord positions are not deterministic, so we need to normalize them // Discord channel positions are relative, so we need to normalize them
// because the user may refer to the channel position via file name template. // so that the user may refer to them more easily in file name templates.
var position = 0; var position = 0;
foreach (var channelJson in responseOrdered) foreach (var channelJson in channelsJson)
{ {
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetNonWhiteSpaceStringOrNull(); var parent = channelJson
.GetPropertyOrNull("parent_id")?
var category = !string.IsNullOrWhiteSpace(parentId) .GetNonWhiteSpaceStringOrNull()?
? categories.GetValueOrDefault(parentId) .Pipe(Snowflake.Parse)
: null; .Pipe(parentsById.GetValueOrDefault);
var channel = Channel.Parse(channelJson, category, position);
yield return Channel.Parse(channelJson, parent, position);
position++; position++;
}
}
}
yield return channel; public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
// User accounts can only fetch threads using the search endpoint
if (tokenKind == TokenKind.User)
{
foreach (var channel in channels)
{
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray())
{
yield return Channel.Parse(threadJson, channel);
currentOffset++;
}
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
// Bot accounts can only fetch threads using the threads endpoint
else
{
// Active threads
{
var parentsById = channels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync($"guilds/{guildId}/threads/active", cancellationToken);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
{
var parent = threadJson
.GetPropertyOrNull("parent_id")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent);
}
}
foreach (var channel in channels)
{
// Public archived threads
{
var response = await GetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/public",
cancellationToken
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
yield return Channel.Parse(threadJson, channel);
}
// Private archived threads
{
var response = await GetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/private",
cancellationToken
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
yield return Channel.Parse(threadJson, channel);
}
} }
} }
} }
@@ -246,7 +333,6 @@ public class DiscordClient
yield break; yield break;
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken); var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
foreach (var roleJson in response.EnumerateArray()) foreach (var roleJson in response.EnumerateArray())
yield return Role.Parse(roleJson); yield return Role.Parse(roleJson);
} }
@@ -260,10 +346,10 @@ public class DiscordClient
return null; return null;
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{memberId}", cancellationToken); var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{memberId}", cancellationToken);
return response?.Pipe(Member.Parse); return response?.Pipe(j => Member.Parse(j, guildId));
} }
public async ValueTask<Invite?> TryGetGuildInviteAsync( public async ValueTask<Invite?> TryGetInviteAsync(
string code, string code,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -271,36 +357,22 @@ public class DiscordClient
return response?.Pipe(Invite.Parse); return response?.Pipe(Invite.Parse);
} }
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(
Snowflake channelId,
CancellationToken cancellationToken = default)
{
try
{
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
return ChannelCategory.Parse(response);
}
// In some cases, the Discord API returns an empty body when requesting channel category.
// Instead, we use an empty channel category as a fallback.
catch (DiscordChatExporterException)
{
return ChannelCategory.Unknown;
}
}
public async ValueTask<Channel> GetChannelAsync( public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId, Snowflake channelId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken); var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
var parentId = response.GetPropertyOrNull("parent_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); var parentId = response
.GetPropertyOrNull("parent_id")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse);
var category = parentId is not null var parent = parentId is not null
? await GetChannelCategoryAsync(parentId.Value, cancellationToken) ? await GetChannelAsync(parentId.Value, cancellationToken)
: null; : null;
return Channel.Parse(response, category); return Channel.Parse(response, parent);
} }
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
@@ -325,18 +397,18 @@ public class DiscordClient
IProgress<Percentage>? progress = null, IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default)
{ {
// Get the last message in the specified range. // Get the last message in the specified range, so we can later calculate the
// This snapshots the boundaries, which means that messages posted after the export started // progress based on the difference between message timestamps.
// will not appear in the output. // This also snapshots the boundaries, which means that messages posted after
// Additionally, it provides the date of the last message, which is used to calculate progress. // the export started will not appear in the output.
var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken); var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken);
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate()) if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
yield break; yield break;
// Keep track of first message in range in order to calculate progress // Keep track of the first message in range in order to calculate the progress
var firstMessage = default(Message); var firstMessage = default(Message);
var currentAfter = after ?? Snowflake.Zero;
var currentAfter = after ?? Snowflake.Zero;
while (true) while (true)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
@@ -350,7 +422,8 @@ public class DiscordClient
var messages = response var messages = response
.EnumerateArray() .EnumerateArray()
.Select(Message.Parse) .Select(Message.Parse)
.Reverse() // reverse because messages appear newest first // Messages are returned from newest to oldest, so we need to reverse them
.Reverse()
.ToArray(); .ToArray();
// Break if there are no messages (can happen if messages are deleted during execution) // Break if there are no messages (can happen if messages are deleted during execution)
@@ -361,11 +434,11 @@ public class DiscordClient
{ {
firstMessage ??= message; firstMessage ??= message;
// Ensure messages are in range (take into account that last message could have been deleted) // Ensure that the messages are in range
if (message.Timestamp > lastMessage.Timestamp) if (message.Timestamp > lastMessage.Timestamp)
yield break; yield break;
// Report progress based on the duration of exported messages divided by total // Report progress based on timestamps
if (progress is not null) if (progress is not null)
{ {
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration(); var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
@@ -373,7 +446,7 @@ public class DiscordClient
progress.Report(Percentage.FromFraction( progress.Report(Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp // Avoid division by zero if all messages have the exact same timestamp
// (which may be the case if there's only one message in the channel) // (which happens when there's only one message in the channel)
totalDuration > TimeSpan.Zero totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration ? exportedDuration / totalDuration
: 1 : 1
@@ -29,15 +29,11 @@ public partial record struct Snowflake
// As number // As number
if (ulong.TryParse(str, NumberStyles.None, formatProvider, out var value)) if (ulong.TryParse(str, NumberStyles.None, formatProvider, out var value))
{
return new Snowflake(value); return new Snowflake(value);
}
// As date // As date
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var instant)) if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var instant))
{
return FromDate(instant); return FromDate(instant);
}
return null; return null;
} }
@@ -59,4 +55,8 @@ public partial record struct Snowflake : IComparable<Snowflake>, IComparable
return Value.CompareTo(other.Value); return Value.CompareTo(other.Value);
} }
public static bool operator >(Snowflake left, Snowflake right) => left.CompareTo(right) > 0;
public static bool operator <(Snowflake left, Snowflake right) => left.CompareTo(right) < 0;
} }
@@ -1,21 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.1.1" /> <PackageReference Include="AsyncKeyedLock" Version="6.2.1" />
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="7.2.4" />
<PackageReference Include="RazorBlade" Version="0.4.2" /> <PackageReference Include="RazorBlade" Version="0.4.3" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.13.8" /> <PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
</ItemGroup> <PackageReference Include="YoutubeExplode" Version="6.3.0" />
<ItemGroup>
<RazorBlade Include="**\*.cshtml" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -18,13 +18,20 @@ public class ChannelExporter
IProgress<Percentage>? progress = null, IProgress<Percentage>? progress = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Check if the channel is empty
if (request.Channel.LastMessageId is null)
throw DiscordChatExporterException.ChannelIsEmpty();
// Check if the 'after' boundary is valid
if (request.After is not null && request.Channel.LastMessageId < request.After)
throw DiscordChatExporterException.ChannelIsEmpty();
// Build context // Build context
var context = new ExportContext(_discord, request); var context = new ExportContext(_discord, request);
await context.PopulateChannelsAndRolesAsync(cancellationToken); await context.PopulateChannelsAndRolesAsync(cancellationToken);
// Export messages // Export messages
await using var messageExporter = new MessageExporter(context); await using var messageExporter = new MessageExporter(context);
await foreach (var message in _discord.GetMessagesAsync( await foreach (var message in _discord.GetMessagesAsync(
request.Channel.Id, request.Channel.Id,
request.After, request.After,
@@ -34,7 +41,7 @@ public class ChannelExporter
{ {
// Resolve members for referenced users // Resolve members for referenced users
foreach (var user in message.GetReferencedUsers()) foreach (var user in message.GetReferencedUsers())
await context.PopulateMemberAsync(user.Id, cancellationToken); await context.PopulateMemberAsync(user, cancellationToken);
// Export the message // Export the message
if (request.MessageFilter.IsMatch(message)) if (request.MessageFilter.IsMatch(message))
@@ -89,11 +89,15 @@ internal partial class CsvMessageWriter : MessageWriter
// Message content // Message content
if (message.Kind.IsSystemNotification()) if (message.Kind.IsSystemNotification())
{ {
await _writer.WriteAsync(CsvEncode(message.GetFallbackContent())); await _writer.WriteAsync(CsvEncode(
message.GetFallbackContent()
));
} }
else else
{ {
await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content, cancellationToken))); await _writer.WriteAsync(CsvEncode(
await FormatMarkdownAsync(message.Content, cancellationToken)
));
} }
await _writer.WriteAsync(','); await _writer.WriteAsync(',');
@@ -38,51 +38,49 @@ internal partial class ExportAssetDownloader
var fileName = GetFileNameFromUrl(url); var fileName = GetFileNameFromUrl(url);
var filePath = Path.Combine(_workingDirPath, fileName); var filePath = Path.Combine(_workingDirPath, fileName);
using (await Locker.LockAsync(filePath, cancellationToken)) using var _ = await Locker.LockAsync(filePath, cancellationToken);
{
if (_pathCache.TryGetValue(url, out var cachedFilePath))
return cachedFilePath;
// Reuse existing files if we're allowed to if (_pathCache.TryGetValue(url, out var cachedFilePath))
if (_reuse && File.Exists(filePath)) return cachedFilePath;
return _pathCache[url] = filePath;
Directory.CreateDirectory(_workingDirPath);
await Http.ResiliencePolicy.ExecuteAsync(async () =>
{
// Download the file
using var response = await Http.Client.GetAsync(url, cancellationToken);
await using (var output = File.Create(filePath))
await response.Content.CopyToAsync(output, cancellationToken);
// Try to set the file date according to the last-modified header
try
{
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
? instant
: (DateTimeOffset?)null
);
if (lastModified is not null)
{
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
}
}
catch
{
// This can apparently fail for some reason.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
// Updating file dates is not a critical task, so we'll just
// ignore exceptions thrown here.
}
});
// Reuse existing files if we're allowed to
if (_reuse && File.Exists(filePath))
return _pathCache[url] = filePath; return _pathCache[url] = filePath;
}
Directory.CreateDirectory(_workingDirPath);
await Http.ResiliencePolicy.ExecuteAsync(async () =>
{
// Download the file
using var response = await Http.Client.GetAsync(url, cancellationToken);
await using (var output = File.Create(filePath))
await response.Content.CopyToAsync(output, cancellationToken);
// Try to set the file date according to the last-modified header
try
{
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
? instant
: (DateTimeOffset?)null
);
if (lastModified is not null)
{
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
}
}
catch
{
// This can apparently fail for some reason.
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
}
});
return _pathCache[url] = filePath;
} }
} }
@@ -98,7 +96,7 @@ internal partial class ExportAssetDownloader
{ {
var urlHash = GetUrlHash(url); var urlHash = GetUrlHash(url);
// Try to extract file name from URL // Try to extract the file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value; var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
// If it's not there, just use the URL hash as the file name // If it's not there, just use the URL hash as the file name
@@ -110,7 +108,7 @@ internal partial class ExportAssetDownloader
var fileExtension = Path.GetExtension(fileName); var fileExtension = Path.GetExtension(fileName);
// Probably not a file extension, just a dot in a long file name // Probably not a file extension, just a dot in a long file name
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708 // https://github.com/Tyrrrz/DiscordChatExporter/pull/812
if (fileExtension.Length > 41) if (fileExtension.Length > 41)
{ {
fileNameWithoutExtension = fileName; fileNameWithoutExtension = fileName;
@@ -20,6 +20,7 @@ internal class ExportContext
private readonly ExportAssetDownloader _assetDownloader; private readonly ExportAssetDownloader _assetDownloader;
public DiscordClient Discord { get; } public DiscordClient Discord { get; }
public ExportRequest Request { get; } public ExportRequest Request { get; }
public ExportContext(DiscordClient discord, public ExportContext(DiscordClient discord,
@@ -29,7 +30,7 @@ internal class ExportContext
Request = request; Request = request;
_assetDownloader = new ExportAssetDownloader( _assetDownloader = new ExportAssetDownloader(
request.OutputAssetsDirPath, request.AssetsDirPath,
request.ShouldReuseAssets request.ShouldReuseAssets
); );
} }
@@ -43,18 +44,38 @@ internal class ExportContext
_roles[role.Id] = role; _roles[role.Id] = role;
} }
// Because members are not pulled in bulk, we need to populate them on demand // Because members cannot be pulled in bulk, we need to populate them on demand
public async ValueTask PopulateMemberAsync(Snowflake id, CancellationToken cancellationToken = default) private async ValueTask PopulateMemberAsync(
Snowflake id,
User? fallbackUser,
CancellationToken cancellationToken = default)
{ {
if (_members.ContainsKey(id)) if (_members.ContainsKey(id))
return; return;
var member = await Discord.TryGetGuildMemberAsync(Request.Guild.Id, id, cancellationToken); var member = await Discord.TryGetGuildMemberAsync(Request.Guild.Id, id, cancellationToken);
// User may have left the guild since they were mentioned.
// Create a dummy member object based on the user info.
if (member is null)
{
var user = fallbackUser ?? await Discord.TryGetUserAsync(id, cancellationToken);
// User may have been deleted since they were mentioned
if (user is not null)
member = Member.CreateFallback(user);
}
// Store the result even if it's null, to avoid re-fetching non-existing members // Store the result even if it's null, to avoid re-fetching non-existing members
_members[id] = member; _members[id] = member;
} }
public async ValueTask PopulateMemberAsync(Snowflake id, CancellationToken cancellationToken = default) =>
await PopulateMemberAsync(id, null, cancellationToken);
public async ValueTask PopulateMemberAsync(User user, CancellationToken cancellationToken = default) =>
await PopulateMemberAsync(user.Id, user, cancellationToken);
public string FormatDate(DateTimeOffset instant) => Request.DateFormat switch public string FormatDate(DateTimeOffset instant) => Request.DateFormat switch
{ {
"unix" => instant.ToUnixTimeSeconds().ToString(), "unix" => instant.ToUnixTimeSeconds().ToString(),
@@ -68,22 +89,17 @@ internal class ExportContext
public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id); public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id);
public Color? TryGetUserColor(Snowflake id) public IReadOnlyList<Role> GetUserRoles(Snowflake id) => TryGetMember(id)?
{ .RoleIds
var member = TryGetMember(id); .Select(TryGetRole)
.WhereNotNull()
.OrderByDescending(r => r.Position)
.ToArray() ?? Array.Empty<Role>();
var memberRoles = member? public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id)
.RoleIds .Where(r => r.Color is not null)
.Select(TryGetRole) .Select(r => r.Color)
.WhereNotNull() .FirstOrDefault();
.ToArray();
return memberRoles?
.Where(r => r.Color is not null)
.OrderByDescending(r => r.Position)
.Select(r => r.Color)
.FirstOrDefault();
}
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default) public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
{ {
@@ -93,34 +109,32 @@ internal class ExportContext
try try
{ {
var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken); var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken);
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
// We want relative path so that the output files can be copied around without breaking. // Prefer relative paths so that the output files can be copied around without breaking references.
// Base directory path may be null if the file is stored at the root or relative to working directory. // If the asset directory is outside of the export directory, use an absolute path instead.
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath) var optimalFilePath =
? Path.GetRelativePath(Request.OutputBaseDirPath, filePath) relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
: filePath; relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
? filePath
: relativeFilePath;
// HACK: for HTML, we need to format the URL properly // For HTML, the path needs to be properly formatted
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight) if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
{ {
// Need to escape each path segment while keeping the directory separators intact // Create a 'file:///' URI and then strip the 'file:///' prefix to allow for relative paths
return string.Join( return new Uri(new Uri("file:///"), optimalFilePath).ToString()[8..];
Path.AltDirectorySeparatorChar,
relativeFilePath
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Select(Uri.EscapeDataString)
);
} }
return relativeFilePath; return optimalFilePath;
} }
// Try to catch only exceptions related to failed HTTP requests // Try to catch only exceptions related to failed HTTP requests
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332 // https://github.com/Tyrrrz/DiscordChatExporter/issues/332
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372 // https://github.com/Tyrrrz/DiscordChatExporter/issues/372
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
{ {
// TODO: add logging so we can be more liberal with catching exceptions // We don't want this to crash the exporting process in case of failure.
// We don't want this to crash the exporting process in case of failure // TODO: add logging so we can be more liberal with catching exceptions.
return url; return url;
} }
} }
@@ -10,36 +10,87 @@ using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
public partial record ExportRequest( public partial class ExportRequest
Guild Guild,
Channel Channel,
string OutputPath,
ExportFormat Format,
Snowflake? After,
Snowflake? Before,
PartitionLimit PartitionLimit,
MessageFilter MessageFilter,
bool ShouldFormatMarkdown,
bool ShouldDownloadAssets,
bool ShouldReuseAssets,
string DateFormat)
{ {
private string? _outputBaseFilePath; public Guild Guild { get; }
public string OutputBaseFilePath => _outputBaseFilePath ??= GetOutputBaseFilePath(
Guild,
Channel,
OutputPath,
Format,
After,
Before
);
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath; public Channel Channel { get; }
public string OutputAssetsDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}"; public string OutputFilePath { get; }
public string OutputDirPath { get; }
public string AssetsDirPath { get; }
public ExportFormat Format { get; }
public Snowflake? After { get; }
public Snowflake? Before { get; }
public PartitionLimit PartitionLimit { get; }
public MessageFilter MessageFilter { get; }
public bool ShouldFormatMarkdown { get; }
public bool ShouldDownloadAssets { get; }
public bool ShouldReuseAssets { get; }
public string DateFormat { get; }
public ExportRequest(
Guild guild,
Channel channel,
string outputPath,
string? assetsDirPath,
ExportFormat format,
Snowflake? after,
Snowflake? before,
PartitionLimit partitionLimit,
MessageFilter messageFilter,
bool shouldFormatMarkdown,
bool shouldDownloadAssets,
bool shouldReuseAssets,
string dateFormat)
{
Guild = guild;
Channel = channel;
Format = format;
After = after;
Before = before;
PartitionLimit = partitionLimit;
MessageFilter = messageFilter;
ShouldFormatMarkdown = shouldFormatMarkdown;
ShouldDownloadAssets = shouldDownloadAssets;
ShouldReuseAssets = shouldReuseAssets;
DateFormat = dateFormat;
OutputFilePath = GetOutputBaseFilePath(
Guild,
Channel,
outputPath,
Format,
After,
Before
);
OutputDirPath = Path.GetDirectoryName(OutputFilePath)!;
AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath)
? FormatPath(
assetsDirPath,
Guild,
Channel,
After,
Before
)
: $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}";
}
} }
public partial record ExportRequest public partial class ExportRequest
{ {
public static string GetDefaultOutputFileName( public static string GetDefaultOutputFileName(
Guild guild, Guild guild,
@@ -51,7 +102,7 @@ public partial record ExportRequest
var buffer = new StringBuilder(); var buffer = new StringBuilder();
// Guild and channel names // Guild and channel names
buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]"); buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]");
// Date range // Date range
if (after is not null || before is not null) if (after is not null || before is not null)
@@ -83,6 +134,35 @@ public partial record ExportRequest
return PathEx.EscapeFileName(buffer.ToString()); return PathEx.EscapeFileName(buffer.ToString());
} }
private static string FormatPath(
string path,
Guild guild,
Channel channel,
Snowflake? after,
Snowflake? before)
{
return Regex.Replace(
path,
"%.",
m => PathEx.EscapeFileName(m.Value switch
{
"%g" => guild.Id.ToString(),
"%G" => guild.Name,
"%t" => channel.Parent?.Id.ToString() ?? "",
"%T" => channel.Parent?.Name ?? "",
"%c" => channel.Id.ToString(),
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Parent?.Position?.ToString() ?? "0",
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
"%%" => "%",
_ => m.Value
})
);
}
private static string GetOutputBaseFilePath( private static string GetOutputBaseFilePath(
Guild guild, Guild guild,
Channel channel, Channel channel,
@@ -91,27 +171,7 @@ public partial record ExportRequest
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null) Snowflake? before = null)
{ {
// Format path var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
var actualOutputPath = Regex.Replace(
outputPath,
"%.",
m => PathEx.EscapeFileName(m.Value switch
{
"%g" => guild.Id.ToString(),
"%G" => guild.Name,
"%t" => channel.Category.Id.ToString(),
"%T" => channel.Category.Name,
"%c" => channel.Id.ToString(),
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0",
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
"%%" => "%",
_ => m.Value
})
);
// Output is a directory // Output is a directory
if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))) if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath)))
@@ -11,6 +11,7 @@ internal class FromMessageFilter : MessageFilter
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) || string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase); string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
} }
@@ -12,6 +12,7 @@ internal class MentionsMessageFilter : MessageFilter
public override bool IsMatch(Message message) => message.MentionedUsers.Any(user => public override bool IsMatch(Message message) => message.MentionedUsers.Any(user =>
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, user.DisplayName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) || string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase) string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
); );
@@ -25,59 +25,59 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_isJumbo = isJumbo; _isJumbo = isJumbo;
} }
protected override async ValueTask<MarkdownNode> VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_buffer.Append(HtmlEncode(text.Text)); _buffer.Append(HtmlEncode(text.Text));
return await base.VisitTextAsync(text, cancellationToken); return default;
} }
protected override async ValueTask<MarkdownNode> VisitFormattingAsync( protected override async ValueTask VisitFormattingAsync(
FormattingNode formatting, FormattingNode formatting,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var (openingTag, closingTag) = formatting.Kind switch var (openingTag, closingTag) = formatting.Kind switch
{ {
FormattingKind.Bold => ( FormattingKind.Bold => (
// language=HTML // lang=html
"<strong>", "<strong>",
// language=HTML // lang=html
"</strong>" "</strong>"
), ),
FormattingKind.Italic => ( FormattingKind.Italic => (
// language=HTML // lang=html
"<em>", "<em>",
// language=HTML // lang=html
"</em>" "</em>"
), ),
FormattingKind.Underline => ( FormattingKind.Underline => (
// language=HTML // lang=html
"<u>", "<u>",
// language=HTML // lang=html
"</u>" "</u>"
), ),
FormattingKind.Strikethrough => ( FormattingKind.Strikethrough => (
// language=HTML // lang=html
"<s>", "<s>",
// language=HTML // lang=html
"</s>" "</s>"
), ),
FormattingKind.Spoiler => ( FormattingKind.Spoiler => (
// language=HTML // lang=html
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""", """<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
// language=HTML // lang=html
"""</span>""" """</span>"""
), ),
FormattingKind.Quote => ( FormattingKind.Quote => (
// language=HTML // lang=html
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""", """<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
// language=HTML // lang=html
"""</div></div>""" """</div></div>"""
), ),
@@ -85,27 +85,76 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
}; };
_buffer.Append(openingTag); _buffer.Append(openingTag);
var result = await base.VisitFormattingAsync(formatting, cancellationToken); await VisitAsync(formatting.Children, cancellationToken);
_buffer.Append(closingTag); _buffer.Append(closingTag);
return result;
} }
protected override async ValueTask<MarkdownNode> VisitInlineCodeBlockAsync( protected override async ValueTask VisitHeadingAsync(
HeadingNode heading,
CancellationToken cancellationToken = default)
{
_buffer.Append(
// lang=html
$"<h{heading.Level}>"
);
await VisitAsync(heading.Children, cancellationToken);
_buffer.Append(
// lang=html
$"</h{heading.Level}>"
);
}
protected override async ValueTask VisitListAsync(
ListNode list,
CancellationToken cancellationToken = default)
{
_buffer.Append(
// lang=html
"<ul>"
);
await VisitAsync(list.Items, cancellationToken);
_buffer.Append(
// lang=html
"</ul>"
);
}
protected override async ValueTask VisitListItemAsync(
ListItemNode listItem,
CancellationToken cancellationToken = default)
{
_buffer.Append(
// lang=html
"<li>"
);
await VisitAsync(listItem.Children, cancellationToken);
_buffer.Append(
// lang=html
"</li>"
);
}
protected override ValueTask VisitInlineCodeBlockAsync(
InlineCodeBlockNode inlineCodeBlock, InlineCodeBlockNode inlineCodeBlock,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
$""" $"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code> <code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code>
""" """
); );
return await base.VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken); return default;
} }
protected override async ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync( protected override ValueTask VisitMultiLineCodeBlockAsync(
MultiLineCodeBlockNode multiLineCodeBlock, MultiLineCodeBlockNode multiLineCodeBlock,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -114,16 +163,16 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: "nohighlight"; : "nohighlight";
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
$""" $"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code> <code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code>
""" """
); );
return await base.VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken); return default;
} }
protected override async ValueTask<MarkdownNode> VisitLinkAsync( protected override async ValueTask VisitLinkAsync(
LinkNode link, LinkNode link,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -135,21 +184,21 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_buffer.Append( _buffer.Append(
!string.IsNullOrWhiteSpace(linkedMessageId) !string.IsNullOrWhiteSpace(linkedMessageId)
// language=HTML // lang=html
? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">""" ? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">"""
// language=HTML // lang=html
: $"""<a href="{HtmlEncode(link.Url)}">""" : $"""<a href="{HtmlEncode(link.Url)}">"""
); );
var result = await base.VisitLinkAsync(link, cancellationToken); await VisitAsync(link.Children, cancellationToken);
// language=HTML _buffer.Append(
_buffer.Append("</a>"); // lang=html
"</a>"
return result; );
} }
protected override async ValueTask<MarkdownNode> VisitEmojiAsync( protected override async ValueTask VisitEmojiAsync(
EmojiNode emoji, EmojiNode emoji,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -157,7 +206,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
$""" $"""
<img <img
loading="lazy" loading="lazy"
@@ -167,18 +216,15 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
src="{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}"> src="{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
""" """
); );
return await base.VisitEmojiAsync(emoji, cancellationToken);
} }
protected override async ValueTask<MarkdownNode> VisitMentionAsync( protected override async ValueTask VisitMentionAsync(MentionNode mention,
MentionNode mention,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
""" """
<span class="chatlog__markdown-mention">@everyone</span> <span class="chatlog__markdown-mention">@everyone</span>
""" """
@@ -187,7 +233,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
else if (mention.Kind == MentionKind.Here) else if (mention.Kind == MentionKind.Here)
{ {
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
""" """
<span class="chatlog__markdown-mention">@here</span> <span class="chatlog__markdown-mention">@here</span>
""" """
@@ -203,23 +249,23 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var member = mention.TargetId?.Pipe(_context.TryGetMember); var member = mention.TargetId?.Pipe(_context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown"; var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown"; var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
$""" $"""
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(nick)}</span> <span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span>
""" """
); );
} }
else if (mention.Kind == MentionKind.Channel) else if (mention.Kind == MentionKind.Channel)
{ {
var channel = mention.TargetId?.Pipe(_context.TryGetChannel); var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
var symbol = channel?.IsVoice == true ? "🔊" : "#"; var symbol = channel?.Kind.IsVoice() == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
$""" $"""
<span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span> <span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span>
""" """
@@ -235,20 +281,18 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
? $""" ? $"""
color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1); color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);
""" """
: ""; : null;
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
$""" $"""
<span class="chatlog__markdown-mention" style="{style}">@{HtmlEncode(name)}</span> <span class="chatlog__markdown-mention" style="{style}">@{HtmlEncode(name)}</span>
""" """
); );
} }
return await base.VisitMentionAsync(mention, cancellationToken);
} }
protected override async ValueTask<MarkdownNode> VisitTimestampAsync( protected override ValueTask VisitTimestampAsync(
TimestampNode timestamp, TimestampNode timestamp,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -261,13 +305,13 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var formattedLong = timestamp.Instant?.ToLocalString("dddd, MMMM d, yyyy h:mm tt") ?? ""; var formattedLong = timestamp.Instant?.ToLocalString("dddd, MMMM d, yyyy h:mm tt") ?? "";
_buffer.Append( _buffer.Append(
// language=HTML // lang=html
$""" $"""
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span> <span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span>
""" """
); );
return await base.VisitTimestampAsync(timestamp, cancellationToken); return default;
} }
} }
@@ -52,12 +52,12 @@ internal class HtmlMessageWriter : MessageWriter
if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7) if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7)
return false; return false;
// Messages must be from the same author // Messages must be sent by the same author
if (message.Author.Id != lastMessage.Author.Id) if (message.Author.Id != lastMessage.Author.Id)
return false; return false;
// If the user changed their name after the last message, their new messages // If the author changed their name after the last message, their new messages
// cannot join an existing group. // cannot join the existing group.
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal)) if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
return false; return false;
} }
@@ -1,4 +1,5 @@
using System.IO; using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@@ -44,19 +45,47 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("id", user.Id.ToString()); _writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name); _writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted); _writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString("nickname", Context.TryGetMember(user.Id)?.Nick ?? user.Name); _writer.WriteString("nickname", Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName);
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex()); _writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot); _writer.WriteBoolean("isBot", user.IsBot);
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
_writer.WriteString( _writer.WriteString(
"avatarUrl", "avatarUrl",
await Context.ResolveAssetUrlAsync(user.AvatarUrl, cancellationToken) await Context.ResolveAssetUrlAsync(
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
cancellationToken
)
); );
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
private async ValueTask WriteRolesAsync(
IReadOnlyList<Role> roles,
CancellationToken cancellationToken = default)
{
_writer.WriteStartArray();
foreach (var role in roles)
{
_writer.WriteStartObject();
_writer.WriteString("id", role.Id.ToString());
_writer.WriteString("name", role.Name);
_writer.WriteString("color", role.Color?.ToHex());
_writer.WriteNumber("position", role.Position);
_writer.WriteEndObject();
}
_writer.WriteEndArray();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteEmbedAuthorAsync( private async ValueTask WriteEmbedAuthorAsync(
EmbedAuthor embedAuthor, EmbedAuthor embedAuthor,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -212,8 +241,8 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject("channel"); _writer.WriteStartObject("channel");
_writer.WriteString("id", Context.Request.Channel.Id.ToString()); _writer.WriteString("id", Context.Request.Channel.Id.ToString());
_writer.WriteString("type", Context.Request.Channel.Kind.ToString()); _writer.WriteString("type", Context.Request.Channel.Kind.ToString());
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString()); _writer.WriteString("categoryId", Context.Request.Channel.Parent?.Id.ToString());
_writer.WriteString("category", Context.Request.Channel.Category.Name); _writer.WriteString("category", Context.Request.Channel.Category);
_writer.WriteString("name", Context.Request.Channel.Name); _writer.WriteString("name", Context.Request.Channel.Name);
_writer.WriteString("topic", Context.Request.Channel.Topic); _writer.WriteString("topic", Context.Request.Channel.Topic);
@@ -233,6 +262,9 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("before", Context.Request.Before?.ToDate()); _writer.WriteString("before", Context.Request.Before?.ToDate());
_writer.WriteEndObject(); _writer.WriteEndObject();
// Timestamp
_writer.WriteString("exportedAt", System.DateTimeOffset.UtcNow);
// Message array (start) // Message array (start)
_writer.WriteStartArray("messages"); _writer.WriteStartArray("messages");
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
@@ -39,7 +39,7 @@ internal partial class MessageExporter : IAsyncDisposable
private async ValueTask<MessageWriter> GetWriterAsync(CancellationToken cancellationToken = default) private async ValueTask<MessageWriter> GetWriterAsync(CancellationToken cancellationToken = default)
{ {
// Ensure partition limit has not been reached // Ensure that the partition limit has not been reached
if (_writer is not null && if (_writer is not null &&
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten)) _context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten))
{ {
@@ -51,8 +51,8 @@ internal partial class MessageExporter : IAsyncDisposable
if (_writer is not null) if (_writer is not null)
return _writer; return _writer;
Directory.CreateDirectory(_context.Request.OutputBaseDirPath); Directory.CreateDirectory(_context.Request.OutputDirPath);
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex); var filePath = GetPartitionFilePath(_context.Request.OutputFilePath, _partitionIndex);
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context); var writer = CreateMessageWriter(filePath, _context.Request.Format, _context);
await writer.WritePreambleAsync(cancellationToken); await writer.WritePreambleAsync(cancellationToken);
@@ -74,11 +74,11 @@ internal partial class MessageExporter
{ {
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex) private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
{ {
// First partition, don't change file name // First partition, don't change the file name
if (partitionIndex <= 0) if (partitionIndex <= 0)
return baseFilePath; return baseFilePath;
// Inject partition index into file name // Inject partition index into the file name
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath); var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
var fileExt = Path.GetExtension(baseFilePath); var fileExt = Path.GetExtension(baseFilePath);
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}"; var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
@@ -40,13 +40,13 @@
var authorMember = Context.TryGetMember(message.Author.Id); var authorMember = Context.TryGetMember(message.Author.Id);
var authorColor = Context.TryGetUserColor(message.Author.Id); var authorColor = Context.TryGetUserColor(message.Author.Id);
var authorNick = message.Author.IsBot var authorDisplayName = message.Author.IsBot
? message.Author.Name ? message.Author.DisplayName
: authorMember?.Nick ?? message.Author.Name; : authorMember?.DisplayName ?? message.Author.DisplayName;
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id"> <div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message"> <div class="chatlog__message">
@{/* System notification */} @* System notification *@
@if (message.Kind.IsSystemNotification()) @if (message.Kind.IsSystemNotification())
{ {
<div class="chatlog__message-aside"> <div class="chatlog__message-aside">
@@ -70,18 +70,18 @@
</div> </div>
<div class="chatlog__message-primary"> <div class="chatlog__message-primary">
@{/* Author name */} @* Author name *@
<span class="chatlog__system-notification-author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorNick</span> <span class="chatlog__system-notification-author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorDisplayName</span>
@{/* Space out the content */} @* Space out the content *@
<span> </span> <span> </span>
@{/* System notification content */} @* System notification content *@
<span class="chatlog__system-notification-content"> <span class="chatlog__system-notification-content">
@if (message.Kind == MessageKind.RecipientAdd && message.MentionedUsers.Any()) @if (message.Kind == MessageKind.RecipientAdd && message.MentionedUsers.Any())
{ {
<span>added </span> <span>added </span>
<a class="chatlog__system-notification-link" title="@message.MentionedUsers.First().FullName">@message.MentionedUsers.First().Name</a> <a class="chatlog__system-notification-link" title="@message.MentionedUsers.First().FullName">@message.MentionedUsers.First().DisplayName</a>
<span> to the group.</span> <span> to the group.</span>
} }
else if (message.Kind == MessageKind.RecipientRemove && message.MentionedUsers.Any()) else if (message.Kind == MessageKind.RecipientRemove && message.MentionedUsers.Any())
@@ -93,7 +93,7 @@
else else
{ {
<span>removed </span> <span>removed </span>
<a class="chatlog__system-notification-link" title="@message.MentionedUsers.First().FullName">@message.MentionedUsers.First().Name</a> <a class="chatlog__system-notification-link" title="@message.MentionedUsers.First().FullName">@message.MentionedUsers.First().DisplayName</a>
<span> from the group.</span> <span> from the group.</span>
} }
} }
@@ -130,7 +130,7 @@
} }
</span> </span>
@{/* Timestamp */} @* Timestamp *@
<span class="chatlog__system-notification-timestamp"> <span class="chatlog__system-notification-timestamp">
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a> <a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span> </span>
@@ -149,7 +149,7 @@
} }
// Avatar // Avatar
<img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(authorMember?.AvatarUrl ?? message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
} }
else else
{ {
@@ -168,12 +168,12 @@
{ {
var referencedUserMember = Context.TryGetMember(message.ReferencedMessage.Author.Id); var referencedUserMember = Context.TryGetMember(message.ReferencedMessage.Author.Id);
var referencedUserColor = Context.TryGetUserColor(message.ReferencedMessage.Author.Id); var referencedUserColor = Context.TryGetUserColor(message.ReferencedMessage.Author.Id);
var referencedUserNick = message.ReferencedMessage.Author.IsBot var referencedUserDisplayName = message.ReferencedMessage.Author.IsBot
? message.ReferencedMessage.Author.Name ? message.ReferencedMessage.Author.DisplayName
: referencedUserMember?.Nick ?? message.ReferencedMessage.Author.Name; : referencedUserMember?.DisplayName ?? message.ReferencedMessage.Author.DisplayName;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(referencedUserMember?.AvatarUrl ?? message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reply-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div> <div class="chatlog__reply-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserDisplayName</div>
<div class="chatlog__reply-content"> <div class="chatlog__reply-content">
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')"> <span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden()) @if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
@@ -201,12 +201,12 @@
{ {
var interactionUserMember = Context.TryGetMember(message.Interaction.User.Id); var interactionUserMember = Context.TryGetMember(message.Interaction.User.Id);
var interactionUserColor = Context.TryGetUserColor(message.Interaction.User.Id); var interactionUserColor = Context.TryGetUserColor(message.Interaction.User.Id);
var interactionUserNick = message.Interaction.User.IsBot var interactionUserDisplayName = message.Interaction.User.IsBot
? message.Interaction.User.Name ? message.Interaction.User.DisplayName
: interactionUserMember?.Nick ?? message.Interaction.User.Name; : interactionUserMember?.DisplayName ?? message.Interaction.User.DisplayName;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(message.Interaction.User.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(interactionUserMember?.AvatarUrl ?? message.Interaction.User.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reply-author" style="@(interactionUserColor is not null ? $"color: rgb({interactionUserColor.Value.R}, {interactionUserColor.Value.G}, {interactionUserColor.Value.B})" : null)" title="@message.Interaction.User.FullName">@interactionUserNick</div> <div class="chatlog__reply-author" style="@(interactionUserColor is not null ? $"color: rgb({interactionUserColor.Value.R}, {interactionUserColor.Value.G}, {interactionUserColor.Value.B})" : null)" title="@message.Interaction.User.FullName">@interactionUserDisplayName</div>
<div class="chatlog__reply-content"> <div class="chatlog__reply-content">
used /@message.Interaction.Name used /@message.Interaction.Name
</div> </div>
@@ -222,10 +222,10 @@
// Header // Header
<div class="chatlog__header"> <div class="chatlog__header">
@{/* Author name */} @* Author name *@
<span class="chatlog__author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorNick</span> <span class="chatlog__author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorDisplayName</span>
@{/* Bot tag */} @* Bot tag *@
@if (message.Author.IsBot) @if (message.Author.IsBot)
{ {
// For cross-posts, the BOT tag is replaced with the SERVER tag // For cross-posts, the BOT tag is replaced with the SERVER tag
@@ -239,22 +239,22 @@
} }
} }
@{/* Timestamp */} @* Timestamp *@
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span> <span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
</div> </div>
} }
@{/* Content */} @* Content *@
@if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null) @if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
{ {
<div class="chatlog__content chatlog__markdown"> <div class="chatlog__content chatlog__markdown">
@{/* Text */} @* Text *@
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) @if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
{ {
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span> <span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
} }
@{/* Edited timestamp */} @* Edited timestamp *@
@if (message.EditedTimestamp is not null) @if (message.EditedTimestamp is not null)
{ {
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span> <span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
@@ -262,17 +262,17 @@
</div> </div>
} }
@{/* Attachments */} @* Attachments *@
@foreach (var attachment in message.Attachments) @foreach (var attachment in message.Attachments)
{ {
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)"> <div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
@{/* Spoiler caption */} @* Spoiler caption *@
@if (attachment.IsSpoiler) @if (attachment.IsSpoiler)
{ {
<div class="chatlog__attachment-spoiler-caption">SPOILER</div> <div class="chatlog__attachment-spoiler-caption">SPOILER</div>
} }
@{/* Attachment preview */} @* Attachment preview *@
@if (attachment.IsImage) @if (attachment.IsImage)
{ {
<a href="@await ResolveAssetUrlAsync(attachment.Url)"> <a href="@await ResolveAssetUrlAsync(attachment.Url)">
@@ -310,7 +310,7 @@
</div> </div>
} }
@{/* Invites */} @* Invites *@
@{ @{
var inviteCodes = MarkdownParser var inviteCodes = MarkdownParser
.ExtractLinks(message.Content) .ExtractLinks(message.Content)
@@ -321,7 +321,7 @@
foreach (var inviteCode in inviteCodes) foreach (var inviteCode in inviteCodes)
{ {
var invite = await Context.Discord.TryGetGuildInviteAsync(inviteCode, CancellationToken); var invite = await Context.Discord.TryGetInviteAsync(inviteCode, CancellationToken);
if (invite is null) if (invite is null)
{ {
continue; continue;
@@ -329,22 +329,22 @@
<div class="chatlog__embed"> <div class="chatlog__embed">
<div class="chatlog__embed-invite-container"> <div class="chatlog__embed-invite-container">
<div class="chatlog__embed-invite-title">Invite to join a server</div> <div class="chatlog__embed-invite-title">@(invite.Channel?.Kind.IsDirect() == true ? "Invite to join a group DM" : "Invite to join a server")</div>
<div class="chatlog__embed-invite"> <div class="chatlog__embed-invite">
<div class="chatlog__embed-invite-guild-icon-container"> <div class="chatlog__embed-invite-guild-icon-container">
<img class="chatlog__embed-invite-guild-icon" src="@await ResolveAssetUrlAsync(invite.Guild?.IconUrl ?? Guild.DirectMessages.IconUrl)" alt="Guild icon" loading="lazy"> <img class="chatlog__embed-invite-guild-icon" src="@await ResolveAssetUrlAsync(invite.Channel?.IconUrl ?? invite.Guild.IconUrl)" alt="Guild icon" loading="lazy">
</div> </div>
<div class="chatlog__embed-invite-info"> <div class="chatlog__embed-invite-info">
<div class="chatlog__embed-invite-guild-name"> <div class="chatlog__embed-invite-guild-name">
<a href="https://discord.gg/@invite.Code"> <a href="https://discord.gg/@invite.Code">
@(invite.Guild?.Name ?? "Unknown guild") @(invite.Guild.Name)
</a> </a>
</div> </div>
<div class="chatlog__embed-invite-channel-name"> <div class="chatlog__embed-invite-channel-name">
<svg class="chatlog__embed-invite-channel-icon"> <svg class="chatlog__embed-invite-channel-icon">
<use href="#channel-icon"></use> <use href="#channel-icon"></use>
</svg> </svg>
<span> @(invite.Channel?.Name ?? "Unknown channel")</span> <span> @(invite.Channel?.Name ?? "Unknown Channel")</span>
</div> </div>
</div> </div>
</div> </div>
@@ -353,7 +353,7 @@
} }
} }
@{/* Embeds */} @* Embeds *@
@foreach (var embed in message.Embeds) @foreach (var embed in message.Embeds)
{ {
// Spotify embed // Spotify embed
@@ -369,7 +369,7 @@
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed) else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
@{/* Color pill */} @* Color pill *@
@if (embed.Color is not null) @if (embed.Color is not null)
{ {
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div> <div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
@@ -382,7 +382,7 @@
<div class="chatlog__embed-content-container"> <div class="chatlog__embed-content-container">
<div class="chatlog__embed-content"> <div class="chatlog__embed-content">
<div class="chatlog__embed-text"> <div class="chatlog__embed-text">
@{/* Embed author */} @* Embed author *@
@if (embed.Author is not null) @if (embed.Author is not null)
{ {
<div class="chatlog__embed-author-container"> <div class="chatlog__embed-author-container">
@@ -407,7 +407,7 @@
</div> </div>
} }
@{/* Embed title */} @* Embed title *@
@if (!string.IsNullOrWhiteSpace(embed.Title)) @if (!string.IsNullOrWhiteSpace(embed.Title))
{ {
<div class="chatlog__embed-title"> <div class="chatlog__embed-title">
@@ -424,7 +424,7 @@
</div> </div>
} }
@{/* Video player */} @* Video player *@
<div class="chatlog__embed-youtube-container"> <div class="chatlog__embed-youtube-container">
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe> <iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
</div> </div>
@@ -447,6 +447,19 @@
</a> </a>
</div> </div>
} }
// Generic video embed
else if (embed.Kind == EmbedKind.Video && !string.IsNullOrWhiteSpace(embed.Url))
{
var embedVideoUrl =
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
embed.Url;
<div class="chatlog__embed">
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
</video>
</div>
}
// Generic gifv embed // Generic gifv embed
else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Url)) else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Url))
{ {
@@ -455,8 +468,8 @@
embed.Url; embed.Url;
<div class="chatlog__embed"> <div class="chatlog__embed">
<video class="chatlog__embed-generic-gifv" loop width="@embed.Video?.Width" height="@embed.Video?.Height" onmouseover="this.play()" onmouseout="this.pause()"> <video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video"> <source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
</video> </video>
</div> </div>
} }
@@ -464,7 +477,7 @@
else else
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
@{/* Color pill */} @* Color pill *@
@if (embed.Color is not null) @if (embed.Color is not null)
{ {
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div> <div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
@@ -477,7 +490,7 @@
<div class="chatlog__embed-content-container"> <div class="chatlog__embed-content-container">
<div class="chatlog__embed-content"> <div class="chatlog__embed-content">
<div class="chatlog__embed-text"> <div class="chatlog__embed-text">
@{/* Embed author */} @* Embed author *@
@if (embed.Author is not null) @if (embed.Author is not null)
{ {
<div class="chatlog__embed-author-container"> <div class="chatlog__embed-author-container">
@@ -502,7 +515,7 @@
</div> </div>
} }
@{/* Embed title */} @* Embed title *@
@if (!string.IsNullOrWhiteSpace(embed.Title)) @if (!string.IsNullOrWhiteSpace(embed.Title))
{ {
<div class="chatlog__embed-title"> <div class="chatlog__embed-title">
@@ -519,7 +532,7 @@
</div> </div>
} }
@{/* Embed description */} @* Embed description *@
@if (!string.IsNullOrWhiteSpace(embed.Description)) @if (!string.IsNullOrWhiteSpace(embed.Description))
{ {
<div class="chatlog__embed-description"> <div class="chatlog__embed-description">
@@ -527,7 +540,7 @@
</div> </div>
} }
@{/* Embed fields */} @* Embed fields *@
@if (embed.Fields.Any()) @if (embed.Fields.Any())
{ {
<div class="chatlog__embed-fields"> <div class="chatlog__embed-fields">
@@ -553,7 +566,7 @@
} }
</div> </div>
@{/* Embed content */} @* Embed content *@
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url)) @if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
{ {
<div class="chatlog__embed-thumbnail-container"> <div class="chatlog__embed-thumbnail-container">
@@ -564,7 +577,7 @@
} }
</div> </div>
@{/* Embed images */} @* Embed images *@
@if (embed.Images.Any()) @if (embed.Images.Any())
{ {
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)"> <div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
@@ -582,18 +595,18 @@
</div> </div>
} }
@{/* Embed footer & icon */} @* Embed footer & icon *@
@if (embed.Footer is not null || embed.Timestamp is not null) @if (embed.Footer is not null || embed.Timestamp is not null)
{ {
<div class="chatlog__embed-footer"> <div class="chatlog__embed-footer">
@{/* Footer icon */} @* Footer icon *@
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{ {
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy"> <img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
} }
<span class="chatlog__embed-footer-text"> <span class="chatlog__embed-footer-text">
@{/* Footer text */} @* Footer text *@
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text)) @if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
{ {
@embed.Footer.Text @embed.Footer.Text
@@ -604,7 +617,7 @@
@(" • ") @(" • ")
} }
@{/* Embed timestamp */} @* Embed timestamp *@
@if (embed.Timestamp is not null) @if (embed.Timestamp is not null)
{ {
@FormatDate(embed.Timestamp.Value) @FormatDate(embed.Timestamp.Value)
@@ -617,11 +630,11 @@
} }
} }
@{/* Stickers */} @* Stickers *@
@foreach (var sticker in message.Stickers) @foreach (var sticker in message.Stickers)
{ {
<div class="chatlog__sticker" title="@sticker.Name"> <div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated) @if (sticker.Format is StickerFormat.Png or StickerFormat.Apng)
{ {
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker"> <img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
} }
@@ -632,7 +645,7 @@
</div> </div>
} }
@{/* Message reactions */} @* Message reactions *@
@if (message.Reactions.Any()) @if (message.Reactions.Any())
{ {
<div class="chatlog__reactions"> <div class="chatlog__reactions">
@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -18,15 +19,15 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer = buffer; _buffer = buffer;
} }
protected override async ValueTask<MarkdownNode> VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_buffer.Append(text.Text); _buffer.Append(text.Text);
return await base.VisitTextAsync(text, cancellationToken); return default;
} }
protected override async ValueTask<MarkdownNode> VisitEmojiAsync( protected override ValueTask VisitEmojiAsync(
EmojiNode emoji, EmojiNode emoji,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -36,11 +37,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: emoji.Name : emoji.Name
); );
return await base.VisitEmojiAsync(emoji, cancellationToken); return default;
} }
protected override async ValueTask<MarkdownNode> VisitMentionAsync( protected override async ValueTask VisitMentionAsync(MentionNode mention,
MentionNode mention,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
@@ -60,9 +60,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken); await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken);
var member = mention.TargetId?.Pipe(_context.TryGetMember); var member = mention.TargetId?.Pipe(_context.TryGetMember);
var name = member?.User.Name ?? "Unknown"; var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
_buffer.Append($"@{name}"); _buffer.Append($"@{displayName}");
} }
else if (mention.Kind == MentionKind.Channel) else if (mention.Kind == MentionKind.Channel)
{ {
@@ -72,7 +72,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"#{name}"); _buffer.Append($"#{name}");
// Voice channel marker // Voice channel marker
if (channel?.IsVoice == true) if (channel?.Kind.IsVoice() == true)
_buffer.Append(" [voice]"); _buffer.Append(" [voice]");
} }
else if (mention.Kind == MentionKind.Role) else if (mention.Kind == MentionKind.Role)
@@ -82,11 +82,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"@{name}"); _buffer.Append($"@{name}");
} }
return await base.VisitMentionAsync(mention, cancellationToken);
} }
protected override async ValueTask<MarkdownNode> VisitTimestampAsync( protected override ValueTask VisitTimestampAsync(
TimestampNode timestamp, TimestampNode timestamp,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -98,7 +96,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: "Invalid date" : "Invalid date"
); );
return await base.VisitTimestampAsync(timestamp, cancellationToken); return default;
} }
} }
@@ -9,13 +9,13 @@ internal static class PlainTextMessageExtensions
public static string GetFallbackContent(this Message message) => message.Kind switch public static string GetFallbackContent(this Message message) => message.Kind switch
{ {
MessageKind.RecipientAdd => message.MentionedUsers.Any() MessageKind.RecipientAdd => message.MentionedUsers.Any()
? $"Added {message.MentionedUsers.First().Name} to the group." ? $"Added {message.MentionedUsers.First().DisplayName} to the group."
: "Added a recipient.", : "Added a recipient.",
MessageKind.RecipientRemove => message.MentionedUsers.Any() MessageKind.RecipientRemove => message.MentionedUsers.Any()
? message.Author.Id == message.MentionedUsers.First().Id ? message.Author.Id == message.MentionedUsers.First().Id
? "Left the group." ? "Left the group."
: $"Removed {message.MentionedUsers.First().Name} from the group." : $"Removed {message.MentionedUsers.First().DisplayName} from the group."
: "Removed a recipient.", : "Removed a recipient.",
MessageKind.Call => MessageKind.Call =>
@@ -193,7 +193,7 @@ internal class PlainTextMessageWriter : MessageWriter
{ {
await _writer.WriteLineAsync(new string('=', 62)); await _writer.WriteLineAsync(new string('=', 62));
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}"); await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}"); await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}");
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
{ {
@@ -15,8 +15,8 @@
? darkVariant ? darkVariant
: lightVariant; : lightVariant;
string GetFontUrl(int weight) => string GetFontUrl(string style, int weight) =>
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff"; $"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/ggsans-{style}-{weight}.woff2";
ValueTask<string> ResolveAssetUrlAsync(string url) => ValueTask<string> ResolveAssetUrlAsync(string url) =>
Context.ResolveAssetUrlAsync(url, CancellationToken); Context.ResolveAssetUrlAsync(url, CancellationToken);
@@ -38,36 +38,76 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
@{/* Styling */} @* Styling *@
<style> <style>
@@font-face { @@font-face {
src: url(@await ResolveAssetUrlAsync(GetFontUrl(300))); src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 400))");
font-family: Whitney; font-family: gg sans;
font-weight: 300;
}
@@font-face {
src: url(@await ResolveAssetUrlAsync(GetFontUrl(400)));
font-family: Whitney;
font-weight: 400; font-weight: 400;
font-style: normal;
} }
@@font-face { @@font-face {
src: url(@await ResolveAssetUrlAsync(GetFontUrl(500))); src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 500))");
font-family: Whitney; font-family: gg sans;
font-weight: 500; font-weight: 500;
font-style: normal;
} }
@@font-face { @@font-face {
src: url(@await ResolveAssetUrlAsync(GetFontUrl(600))); src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 600))");
font-family: Whitney; font-family: gg sans;
font-weight: 600; font-weight: 600;
font-style: normal;
} }
@@font-face { @@font-face {
src: url(@await ResolveAssetUrlAsync(GetFontUrl(700))); src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 700))");
font-family: Whitney; font-family: gg sans;
font-weight: 700; font-weight: 700;
font-style: normal;
}
@@font-face {
src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 800))");
font-family: gg sans;
font-weight: 800;
font-style: normal;
}
@@font-face {
src: url("@await ResolveAssetUrlAsync(GetFontUrl("italic", 400))");
font-family: gg sans;
font-weight: 400;
font-style: italic;
}
@@font-face {
src: url("@await ResolveAssetUrlAsync(GetFontUrl("italic", 500))");
font-family: gg sans;
font-weight: 500;
font-style: italic;
}
@@font-face {
src: url("@await ResolveAssetUrlAsync(GetFontUrl("italic", 600))");
font-family: gg sans;
font-weight: 600;
font-style: italic;
}
@@font-face {
src: url("@await ResolveAssetUrlAsync(GetFontUrl("italic", 700))");
font-family: gg sans;
font-weight: 700;
font-style: italic;
}
@@font-face {
src: url("@await ResolveAssetUrlAsync(GetFontUrl("italic", 800))");
font-family: gg sans;
font-weight: 800;
font-style: italic;
} }
html, body { html, body {
@@ -75,7 +115,7 @@
padding: 0; padding: 0;
background-color: @Themed("#36393e", "#ffffff"); background-color: @Themed("#36393e", "#ffffff");
color: @Themed("#dcddde", "#23262a"); color: @Themed("#dcddde", "#23262a");
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: "gg sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 17px; font-size: 17px;
font-weight: @Themed("400", "500"); font-weight: @Themed("400", "500");
scroll-behavior: smooth; scroll-behavior: smooth;
@@ -582,24 +622,6 @@
font-weight: 500; font-weight: 500;
} }
.chatlog__embed-generic-image {
object-fit: contain;
object-position: left;
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-generic-gifv {
object-fit: contain;
object-position: left;
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-invite-container { .chatlog__embed-invite-container {
min-width: 320px; min-width: 320px;
padding: 0.6rem 0.7rem; padding: 0.6rem 0.7rem;
@@ -650,6 +672,33 @@
font-weight: 600; font-weight: 600;
} }
.chatlog__embed-generic-image {
object-fit: contain;
object-position: left;
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-generic-video {
object-fit: contain;
object-position: left;
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-generic-gifv {
object-fit: contain;
object-position: left;
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-spotify { .chatlog__embed-spotify {
border: 0; border: 0;
} }
@@ -709,6 +758,36 @@
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.chatlog__markdown h1 {
margin: 1rem 0 0.5rem;
color: @Themed("#f2f3f5", "#060607");
font-size: 1.5rem;
line-height: 1;
}
.chatlog__markdown h2 {
margin: 1rem 0 0.5rem;
color: @Themed("#f2f3f5", "#060607");
font-size: 1.25rem;
line-height: 1;
}
.chatlog__markdown h3 {
margin: 1rem 0 0.5rem;
color: @Themed("#f2f3f5", "#060607");
font-size: 1rem;
line-height: 1;
}
.chatlog__markdown h1:first-child, h2:first-child, h3:first-child {
margin-top: 0.5rem;
}
.chatlog__markdown ul, ol {
margin: 0 0 0 1rem;
padding: 0;
}
.chatlog__markdown-preserve { .chatlog__markdown-preserve {
white-space: pre-wrap; white-space: pre-wrap;
} }
@@ -816,7 +895,7 @@
} }
</style> </style>
@{/* Syntax highlighting */} @* Syntax highlighting *@
<link rel="stylesheet" href="@await ResolveAssetUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{ThemeName.ToLowerInvariant()}.min.css")"> <link rel="stylesheet" href="@await ResolveAssetUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{ThemeName.ToLowerInvariant()}.min.css")">
<script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script> <script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
<script> <script>
@@ -825,7 +904,7 @@
}); });
</script> </script>
@{/* Lottie animation support */} @* Lottie animation support *@
<script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script> <script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@@ -846,7 +925,7 @@
}); });
</script> </script>
@{/* Scripts */} @* Scripts *@
<script> <script>
function scrollToMessage(event, id) { function scrollToMessage(event, id) {
const element = document.getElementById('chatlog__message-container-' + id); const element = document.getElementById('chatlog__message-container-' + id);
@@ -883,7 +962,7 @@
} }
</script> </script>
@{/* Icons */} @* Icons *@
<svg style="display: none" xmlns="http://www.w3.org/2000/svg"> <svg style="display: none" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<symbol id="attachment-icon" viewBox="0 0 720 960"> <symbol id="attachment-icon" viewBox="0 0 720 960">
@@ -925,7 +1004,7 @@
</div> </div>
<div class="preamble__entries-container"> <div class="preamble__entries-container">
<div class="preamble__entry">@Context.Request.Guild.Name</div> <div class="preamble__entry">@Context.Request.Guild.Name</div>
<div class="preamble__entry">@Context.Request.Channel.Category.Name / @Context.Request.Channel.Name</div> <div class="preamble__entry">@Context.Request.Channel.Category / @Context.Request.Channel.Name</div>
@if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) @if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
{ {
@@ -952,7 +1031,7 @@
</div> </div>
</div> </div>
@{/* Preamble cuts off at this point */} @* Preamble cuts off at this point *@
<!--wmm:ignore--> <!--wmm:ignore-->
<div class="chatlog"> <div class="chatlog">
<!--/wmm:ignore--> <!--/wmm:ignore-->
@@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown;
internal record HeadingNode(
int Level,
IReadOnlyList<MarkdownNode> Children
) : MarkdownNode, IContainerNode;
@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown;
internal record ListItemNode(IReadOnlyList<MarkdownNode> Children) : MarkdownNode, IContainerNode;
@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown;
internal record ListNode(IReadOnlyList<ListItemNode> Items) : MarkdownNode;
@@ -24,41 +24,40 @@ internal static partial class MarkdownParser
/* Formatting */ /* Formatting */
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character until the earliest double asterisk not followed by an asterisk // There must be exactly two closing asterisks.
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk // There must be exactly one closing asterisk.
// Opening asterisk must not be followed by whitespace // Opening asterisk must not be followed by whitespace.
// Closing asterisk must not be preceded by whitespace // Closing asterisk must not be preceded by whitespace.
new Regex(@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character until the earliest triple asterisk not followed by an asterisk // There must be exactly three closing asterisks.
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)) (s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher))
); );
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character except underscore until an underscore // Closing underscore must not be followed by a word character.
// Closing underscore must not be followed by a word character new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
new Regex(@"_([^_]+)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character until the earliest double underscore not followed by an underscore // There must be exactly two closing underscores.
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher = private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownNode>(
// Capture any character until the earliest triple underscore not followed by an underscore // There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode( (s, m) => new FormattingNode(
FormattingKind.Italic, FormattingKind.Italic,
@@ -66,62 +65,63 @@ internal static partial class MarkdownParser
) )
); );
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher = private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new RegexMatcher<MarkdownNode>( new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
// Capture any character until the earliest double tilde (s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline), );
(s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character until the earliest double pipe
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character until the end of the line // Include the linebreak in the content so that the lines are preserved in quotes.
// Opening 'greater than' character must be followed by whitespace new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
// Text content is optional
new Regex(@"^>\s(.*\n?)", DefaultRegexOptions),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher = private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
new RegexMatcher<MarkdownNode>( // Include the linebreaks in the content, so that the lines are preserved in quotes.
// Repeatedly capture any character until the end of the line new Regex(@"(?:^>\s(.+\n?)){2,}", DefaultRegexOptions),
// This one is tricky as it ends up producing multiple separate captures which need to be joined (s, m) => new FormattingNode(
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions), FormattingKind.Quote,
(_, m) => new FormattingNode( m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
FormattingKind.Quote, )
Parse( );
// Combine all captures into a single string
string.Concat(m.Groups[1].Captures.Select(c => c.Value))
)
)
);
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character until the end of the input
// Opening 'greater than' characters must be followed by whitespace
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher = new RegexMatcher<MarkdownNode>(
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(s, m) => new ListNode(
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
)
);
/* Code blocks */ /* Code blocks */
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any character except backtick until a backtick // One or two backticks are allowed, but they must match on both sides.
// Blank lines at the beginning and end of content are trimmed
// There can be either one or two backticks, but equal number on both sides
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n')) (_, m) => new InlineCodeBlockNode(m.Groups[2].Value)
); );
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture language identifier and then any character until the earliest triple backtick // Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
// Language identifier is one word immediately after opening backticks, followed immediately by newline // Blank lines at the beginning and at the end of content are trimmed.
// Blank lines at the beginning and end of content are trimmed
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
(_, m) => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')) (_, m) => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
); );
@@ -207,7 +207,7 @@ internal static partial class MarkdownParser
); );
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture :thinking: for known emoji codes // Capture :thinking:
new Regex(@":([\w_]+):", DefaultRegexOptions), new Regex(@":([\w_]+):", DefaultRegexOptions),
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n)) (_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
); );
@@ -224,15 +224,9 @@ internal static partial class MarkdownParser
/* Links */ /* Links */
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture [title](link)
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any non-whitespace character after http:// or https:// // Any non-whitespace character after http:// or https://
// until the last punctuation character or whitespace // until the last punctuation character or whitespace.
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions), new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
(_, m) => new LinkNode(m.Groups[1].Value) (_, m) => new LinkNode(m.Groups[1].Value)
); );
@@ -243,32 +237,38 @@ internal static partial class MarkdownParser
(_, m) => new LinkNode(m.Groups[1].Value) (_, m) => new LinkNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture [title](link)
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
);
/* Text */ /* Text */
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
// Capture the shrug kaomoji // Capture the shrug kaomoji.
// This escapes it from matching for formatting // This escapes it from matching for formatting.
@"¯\_(ツ)_/¯", @"¯\_(ツ)_/¯",
s => new TextNode(s.ToString()) s => new TextNode(s.ToString())
); );
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture some specific emoji that don't get rendered // Capture some specific emoji that don't get rendered.
// This escapes it from matching for emoji // This escapes them from matching for emoji.
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions), new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value) (_, m) => new TextNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any "symbol/other" character or surrogate pair preceded by a backslash // Capture any "symbol/other" character or surrogate pair preceded by a backslash.
// This escapes it from matching for emoji // This escapes them from matching for emoji.
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions), new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value) (_, m) => new TextNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
// This escapes it from matching for formatting or other tokens // This escapes them from matching for formatting or other tokens.
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions), new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value) (_, m) => new TextNode(m.Groups[1].Value)
); );
@@ -310,9 +310,8 @@ internal static partial class MarkdownParser
} }
); );
// Combine all matchers into one
// Matchers that have similar patterns are ordered from most specific to least specific // Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<MarkdownNode> AggregateNodeMatcher = new AggregateMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>(
// Escaped text // Escaped text
ShrugTextNodeMatcher, ShrugTextNodeMatcher,
IgnoredEmojiTextNodeMatcher, IgnoredEmojiTextNodeMatcher,
@@ -331,6 +330,8 @@ internal static partial class MarkdownParser
MultiLineQuoteNodeMatcher, MultiLineQuoteNodeMatcher,
RepeatedSingleLineQuoteNodeMatcher, RepeatedSingleLineQuoteNodeMatcher,
SingleLineQuoteNodeMatcher, SingleLineQuoteNodeMatcher,
HeadingNodeMatcher,
ListNodeMatcher,
// Code blocks // Code blocks
MultiLineCodeBlockNodeMatcher, MultiLineCodeBlockNodeMatcher,
@@ -344,7 +345,7 @@ internal static partial class MarkdownParser
RoleMentionNodeMatcher, RoleMentionNodeMatcher,
// Links // Links
TitledLinkNodeMatcher, MaskedLinkNodeMatcher,
AutoLinkNodeMatcher, AutoLinkNodeMatcher,
HiddenLinkNodeMatcher, HiddenLinkNodeMatcher,
@@ -358,7 +359,7 @@ internal static partial class MarkdownParser
); );
// Minimal set of matchers for non-multimedia formats (e.g. plain text) // Minimal set of matchers for non-multimedia formats (e.g. plain text)
private static readonly IMatcher<MarkdownNode> MinimalAggregateNodeMatcher = new AggregateMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher = new AggregateMatcher<MarkdownNode>(
// Mentions // Mentions
EveryoneMentionNodeMatcher, EveryoneMentionNodeMatcher,
HereMentionNodeMatcher, HereMentionNodeMatcher,
@@ -383,13 +384,13 @@ internal static partial class MarkdownParser
internal static partial class MarkdownParser internal static partial class MarkdownParser
{ {
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) => private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
Parse(segment, AggregateNodeMatcher); Parse(segment, NodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string markdown) => public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
Parse(new StringSegment(markdown)); Parse(new StringSegment(markdown));
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) => private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
Parse(segment, MinimalAggregateNodeMatcher); Parse(segment, MinimalNodeMatcher);
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) => public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
ParseMinimal(new StringSegment(markdown)); ParseMinimal(new StringSegment(markdown));
@@ -7,82 +7,127 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
internal abstract class MarkdownVisitor internal abstract class MarkdownVisitor
{ {
protected virtual ValueTask<MarkdownNode> VisitTextAsync( protected virtual ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) => default;
new(text);
protected virtual async ValueTask<MarkdownNode> VisitFormattingAsync( protected virtual async ValueTask VisitFormattingAsync(
FormattingNode formatting, FormattingNode formatting,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default) =>
{
await VisitAsync(formatting.Children, cancellationToken); await VisitAsync(formatting.Children, cancellationToken);
return formatting;
}
protected virtual ValueTask<MarkdownNode> VisitInlineCodeBlockAsync( protected virtual async ValueTask VisitHeadingAsync(
HeadingNode heading,
CancellationToken cancellationToken = default) =>
await VisitAsync(heading.Children, cancellationToken);
protected virtual async ValueTask VisitListAsync(
ListNode list,
CancellationToken cancellationToken = default) =>
await VisitAsync(list.Items, cancellationToken);
protected virtual async ValueTask VisitListItemAsync(
ListItemNode listItem,
CancellationToken cancellationToken = default) =>
await VisitAsync(listItem.Children, cancellationToken);
protected virtual ValueTask VisitInlineCodeBlockAsync(
InlineCodeBlockNode inlineCodeBlock, InlineCodeBlockNode inlineCodeBlock,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) => default;
new(inlineCodeBlock);
protected virtual ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync( protected virtual ValueTask VisitMultiLineCodeBlockAsync(
MultiLineCodeBlockNode multiLineCodeBlock, MultiLineCodeBlockNode multiLineCodeBlock,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) => default;
new(multiLineCodeBlock);
protected virtual async ValueTask<MarkdownNode> VisitLinkAsync( protected virtual async ValueTask VisitLinkAsync(
LinkNode link, LinkNode link,
CancellationToken cancellationToken = default) =>
await VisitAsync(link.Children, cancellationToken);
protected virtual ValueTask VisitEmojiAsync(
EmojiNode emoji,
CancellationToken cancellationToken = default) => default;
protected virtual ValueTask VisitMentionAsync(
MentionNode mention,
CancellationToken cancellationToken = default) => default;
protected virtual ValueTask VisitTimestampAsync(
TimestampNode timestamp,
CancellationToken cancellationToken = default) => default;
public async ValueTask VisitAsync(
MarkdownNode node,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
await VisitAsync(link.Children, cancellationToken); if (node is TextNode text)
return link;
}
protected virtual ValueTask<MarkdownNode> VisitEmojiAsync(
EmojiNode emoji,
CancellationToken cancellationToken = default) =>
new(emoji);
protected virtual ValueTask<MarkdownNode> VisitMentionAsync(
MentionNode mention,
CancellationToken cancellationToken = default) =>
new(mention);
protected virtual ValueTask<MarkdownNode> VisitTimestampAsync(
TimestampNode timestamp,
CancellationToken cancellationToken = default) =>
new(timestamp);
public async ValueTask<MarkdownNode> VisitAsync(
MarkdownNode node,
CancellationToken cancellationToken = default) => node switch
{ {
TextNode text => await VisitTextAsync(text, cancellationToken);
await VisitTextAsync(text, cancellationToken), return;
}
FormattingNode formatting => if (node is FormattingNode formatting)
await VisitFormattingAsync(formatting, cancellationToken), {
await VisitFormattingAsync(formatting, cancellationToken);
return;
}
InlineCodeBlockNode inlineCodeBlock => if (node is HeadingNode heading)
await VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken), {
await VisitHeadingAsync(heading, cancellationToken);
return;
}
MultiLineCodeBlockNode multiLineCodeBlock => if (node is ListNode list)
await VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken), {
await VisitListAsync(list, cancellationToken);
return;
}
LinkNode link => if (node is ListItemNode listItem)
await VisitLinkAsync(link, cancellationToken), {
await VisitListItemAsync(listItem, cancellationToken);
return;
}
EmojiNode emoji => if (node is InlineCodeBlockNode inlineCodeBlock)
await VisitEmojiAsync(emoji, cancellationToken), {
await VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken);
return;
}
MentionNode mention => if (node is MultiLineCodeBlockNode multiLineCodeBlock)
await VisitMentionAsync(mention, cancellationToken), {
await VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken);
return;
}
TimestampNode timestamp => if (node is LinkNode link)
await VisitTimestampAsync(timestamp, cancellationToken), {
await VisitLinkAsync(link, cancellationToken);
return;
}
_ => throw new ArgumentOutOfRangeException(nameof(node)) if (node is EmojiNode emoji)
}; {
await VisitEmojiAsync(emoji, cancellationToken);
return;
}
if (node is MentionNode mention)
{
await VisitMentionAsync(mention, cancellationToken);
return;
}
if (node is TimestampNode timestamp)
{
await VisitTimestampAsync(timestamp, cancellationToken);
return;
}
throw new ArgumentOutOfRangeException(nameof(node));
}
public async ValueTask VisitAsync( public async ValueTask VisitAsync(
IEnumerable<MarkdownNode> nodes, IEnumerable<MarkdownNode> nodes,
@@ -4,9 +4,9 @@ using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
public static class AsyncExtensions public static class AsyncCollectionExtensions
{ {
private static async ValueTask<IReadOnlyList<T>> AggregateAsync<T>( private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
this IAsyncEnumerable<T> asyncEnumerable) this IAsyncEnumerable<T> asyncEnumerable)
{ {
var list = new List<T>(); var list = new List<T>();
@@ -19,5 +19,5 @@ public static class AsyncExtensions
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>( public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
this IAsyncEnumerable<T> asyncEnumerable) => this IAsyncEnumerable<T> asyncEnumerable) =>
asyncEnumerable.AggregateAsync().GetAwaiter(); asyncEnumerable.CollectAsync().GetAwaiter();
} }
@@ -8,8 +8,8 @@ public static class BinaryExtensions
{ {
var buffer = new StringBuilder(2 * data.Length); var buffer = new StringBuilder(2 * data.Length);
foreach (var t in data) foreach (var b in data)
buffer.Append(t.ToString("X2")); buffer.Append(b.ToString("X2"));
return buffer.ToString(); return buffer.ToString();
} }
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class CollectionExtensions public static class CollectionExtensions
{ {
public static IEnumerable<T> Enumerate<T>(this T obj) public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj)
{ {
yield return obj; yield return obj;
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
@@ -10,4 +11,7 @@ public static class GenericExtensions
!predicate(value) !predicate(value)
? value ? value
: null; : null;
public static T? NullIfDefault<T>(this T value) where T : struct =>
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
} }
@@ -1,40 +0,0 @@
using System;
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Utils;
public static class FileFormat
{
private static readonly HashSet<string> ImageExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".jpg",
".jpeg",
".png",
".gif",
".gifv",
".bmp",
".webp"
};
public static bool IsImage(string format) => ImageExtensions.Contains(format);
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4",
".webm",
".mov"
};
public static bool IsVideo(string format) => VideoExtensions.Contains(format);
private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp3",
".wav",
".ogg",
".flac",
".m4a"
};
public static bool IsAudio(string format) => AudioExtensions.Contains(format);
}
+1 -1
View File
@@ -16,7 +16,7 @@ public static class Http
private static bool IsRetryableStatusCode(HttpStatusCode statusCode) => private static bool IsRetryableStatusCode(HttpStatusCode statusCode) =>
statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout || statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout ||
// Treat all server-side errors as retryable. // Treat all server-side errors as retryable
// https://github.com/Tyrrrz/DiscordChatExporter/issues/908 // https://github.com/Tyrrrz/DiscordChatExporter/issues/908
(int)statusCode >= 500; (int)statusCode >= 500;
@@ -42,7 +42,7 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
set => SetValue(SelectedItemsProperty, value); set => SetValue(SelectedItemsProperty, value);
} }
// Propagate selected items from model to view // Propagate selected items from the model to the view
private void SelectItems() private void SelectItems()
{ {
_viewHandled = true; _viewHandled = true;
@@ -57,7 +57,7 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
_viewHandled = false; _viewHandled = false;
} }
// Propagate selected items from view to model // Propagate selected items from the view to the model
private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args) private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args)
{ {
if (_viewHandled) return; if (_viewHandled) return;
@@ -66,7 +66,6 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray(); SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
} }
// Re-select items when the set of items changes
private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args) private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args)
{ {
if (_viewHandled) return; if (_viewHandled) return;
@@ -82,7 +81,6 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged += OnListBoxItemsChanged; ((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged += OnListBoxItemsChanged;
} }
/// <inheritdoc />
protected override void OnDetaching() protected override void OnDetaching()
{ {
base.OnDetaching(); base.OnDetaching();
+5 -8
View File
@@ -17,8 +17,8 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
{ {
base.OnStart(); base.OnStart();
// Set default theme // Set the default theme.
// (preferred theme will be set later, once the settings are loaded) // Preferred theme will be set later, once the settings are loaded.
App.SetLightTheme(); App.SetLightTheme();
} }
@@ -26,20 +26,17 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
{ {
base.ConfigureIoC(builder); base.ConfigureIoC(builder);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope(); builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Bind view model factory
builder.Bind<IViewModelFactory>().ToAbstractFactory(); builder.Bind<IViewModelFactory>().ToAbstractFactory();
} }
#if !DEBUG #if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e) protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
{ {
base.OnUnhandledException(e); base.OnUnhandledException(args);
MessageBox.Show( MessageBox.Show(
e.Exception.ToString(), args.Exception.ToString(),
"Error occured", "Error occured",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error MessageBoxImage.Error
@@ -13,17 +13,17 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FuckRussia" Version="1.0.1" PrivateAssets="all" /> <PackageReference Include="Cogwheel" Version="2.0.3" />
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="MaterialDesignColors" Version="2.1.1" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
<PackageReference Include="MaterialDesignThemes" Version="4.7.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="MaterialDesignColors" Version="2.1.4" />
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" /> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" /> <PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.4" /> <PackageReference Include="Onova" Version="2.6.10" />
<PackageReference Include="Stylet" Version="1.3.6" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" /> <PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.4.0" PrivateAssets="all" /> <PackageReference Include="Stylet" Version="1.3.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,11 +1,12 @@
using System; using System;
using System.IO;
using Cogwheel;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using Microsoft.Win32; using Microsoft.Win32;
using Tyrrrz.Settings;
namespace DiscordChatExporter.Gui.Services; namespace DiscordChatExporter.Gui.Services;
public partial class SettingsService : SettingsManager public partial class SettingsService : SettingsBase
{ {
public bool IsUkraineSupportMessageEnabled { get; set; } = true; public bool IsUkraineSupportMessageEnabled { get; set; } = true;
@@ -35,14 +36,24 @@ public partial class SettingsService : SettingsManager
public bool LastShouldReuseAssets { get; set; } public bool LastShouldReuseAssets { get; set; }
public string? LastAssetsDirPath { get; set; }
public SettingsService() public SettingsService()
: base(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"))
{ {
Configuration.StorageSpace = StorageSpace.Instance;
Configuration.SubDirectoryPath = "";
Configuration.FileName = "Settings.dat";
} }
public bool ShouldSerializeLastToken() => IsTokenPersisted; public override void Save()
{
// Clear the token if it's not supposed to be persisted
var lastToken = LastToken;
if (!IsTokenPersisted)
LastToken = null;
base.Save();
LastToken = lastToken;
}
} }
public partial class SettingsService public partial class SettingsService
+7 -5
View File
@@ -6,13 +6,15 @@ internal static class ProcessEx
{ {
public static void StartShellExecute(string path) public static void StartShellExecute(string path)
{ {
var startInfo = new ProcessStartInfo(path) using var process = new Process
{ {
UseShellExecute = true StartInfo = new ProcessStartInfo
{
FileName = path,
UseShellExecute = true
}
}; };
using (Process.Start(startInfo)) process.Start();
{
}
} }
} }
@@ -38,16 +38,16 @@ public class DashboardViewModel : PropertyChangedBase
public string? Token { get; set; } public string? Token { get; set; }
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; } private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? ChannelsByGuild { get; set; }
public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.Keys.ToArray(); public IReadOnlyList<Guild>? AvailableGuilds => ChannelsByGuild?.Keys.ToArray();
public Guild? SelectedGuild { get; set; } public Guild? SelectedGuild { get; set; }
public bool IsDirectMessageGuildSelected => SelectedGuild?.Id == Guild.DirectMessages.Id; public bool IsDirectMessageGuildSelected => SelectedGuild?.Id == Guild.DirectMessages.Id;
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
? GuildChannelMap?[SelectedGuild] ? ChannelsByGuild?[SelectedGuild]
: null; : null;
public IReadOnlyList<Channel>? SelectedChannels { get; set; } public IReadOnlyList<Channel>? SelectedChannels { get; set; }
@@ -65,16 +65,21 @@ public class DashboardViewModel : PropertyChangedBase
_progressMuxer = Progress.CreateMuxer().WithAutoReset(); _progressMuxer = Progress.CreateMuxer().WithAutoReset();
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); this.Bind(
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); o => o.IsBusy,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
Progress.Bind(
o => o.Current,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
} }
public void OnViewLoaded() public void OnViewLoaded()
{ {
if (_settingsService.LastToken is not null) if (!string.IsNullOrWhiteSpace(_settingsService.LastToken))
{
Token = _settingsService.LastToken; Token = _settingsService.LastToken;
}
} }
public async void ShowSettings() public async void ShowSettings()
@@ -103,17 +108,17 @@ public class DashboardViewModel : PropertyChangedBase
var discord = new DiscordClient(token); var discord = new DiscordClient(token);
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>(); var channelsByGuild = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync()) await foreach (var guild in discord.GetUserGuildsAsync())
{ {
guildChannelMap[guild] = (await discord.GetGuildChannelsAsync(guild.Id)) channelsByGuild[guild] = (await discord.GetGuildChannelsAsync(guild.Id))
.Where(c => c.Kind != ChannelKind.GuildCategory) .Where(c => c.Kind != ChannelKind.GuildCategory)
.ToArray(); .ToArray();
} }
_discord = discord; _discord = discord;
GuildChannelMap = guildChannelMap; ChannelsByGuild = channelsByGuild;
SelectedGuild = guildChannelMap.Keys.FirstOrDefault(); SelectedGuild = channelsByGuild.Keys.FirstOrDefault();
} }
catch (DiscordChatExporterException ex) when (!ex.IsFatal) catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{ {
@@ -158,14 +163,11 @@ public class DashboardViewModel : PropertyChangedBase
var exporter = new ChannelExporter(_discord); var exporter = new ChannelExporter(_discord);
var channelProgressPairs = dialog var channelProgressPairs = dialog.Channels!.Select(c => new
.Channels! {
.Select(c => new Channel = c,
{ Progress = _progressMuxer.CreateInput()
Channel = c, }).ToArray();
Progress = _progressMuxer.CreateInput()
})
.ToArray();
var successfulExportCount = 0; var successfulExportCount = 0;
@@ -186,6 +188,7 @@ public class DashboardViewModel : PropertyChangedBase
dialog.Guild!, dialog.Guild!,
channel, channel,
dialog.OutputPath!, dialog.OutputPath!,
dialog.AssetsDirPath,
dialog.SelectedFormat, dialog.SelectedFormat,
dialog.After?.Pipe(Snowflake.FromDate), dialog.After?.Pipe(Snowflake.FromDate),
dialog.Before?.Pipe(Snowflake.FromDate), dialog.Before?.Pipe(Snowflake.FromDate),
@@ -214,7 +217,7 @@ public class DashboardViewModel : PropertyChangedBase
} }
); );
// Notify of overall completion // Notify of the overall completion
if (successfulExportCount > 0) if (successfulExportCount > 0)
{ {
_eventAggregator.Publish( _eventAggregator.Publish(
@@ -65,6 +65,8 @@ public class ExportSetupViewModel : DialogScreen
public bool ShouldReuseAssets { get; set; } public bool ShouldReuseAssets { get; set; }
public string? AssetsDirPath { get; set; }
public bool IsAdvancedSectionDisplayed { get; set; } public bool IsAdvancedSectionDisplayed { get; set; }
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService) public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
@@ -79,15 +81,18 @@ public class ExportSetupViewModel : DialogScreen
ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown; ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown;
ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets; ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets;
ShouldReuseAssets = _settingsService.LastShouldReuseAssets; ShouldReuseAssets = _settingsService.LastShouldReuseAssets;
AssetsDirPath = _settingsService.LastAssetsDirPath;
// Show the "advanced options" section by default if any // Show the "advanced options" section by default if any
// of the advanced options are set to non-default values. // of the advanced options are set to non-default values.
IsAdvancedSectionDisplayed = IsAdvancedSectionDisplayed =
After != default || After is not null ||
Before != default || Before is not null ||
!string.IsNullOrWhiteSpace(PartitionLimitValue) || !string.IsNullOrWhiteSpace(PartitionLimitValue) ||
!string.IsNullOrWhiteSpace(MessageFilterValue) || !string.IsNullOrWhiteSpace(MessageFilterValue) ||
ShouldDownloadAssets != default; ShouldDownloadAssets ||
ShouldReuseAssets ||
!string.IsNullOrWhiteSpace(AssetsDirPath);
} }
public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed; public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed;
@@ -107,18 +112,25 @@ public class ExportSetupViewModel : DialogScreen
var extension = SelectedFormat.GetFileExtension(); var extension = SelectedFormat.GetFileExtension();
var filter = $"{extension.ToUpperInvariant()} files|*.{extension}"; var filter = $"{extension.ToUpperInvariant()} files|*.{extension}";
var outputPath = _dialogManager.PromptSaveFilePath(filter, defaultFileName); var path = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
if (!string.IsNullOrWhiteSpace(outputPath)) if (!string.IsNullOrWhiteSpace(path))
OutputPath = outputPath; OutputPath = path;
} }
else else
{ {
var outputPath = _dialogManager.PromptDirectoryPath(); var path = _dialogManager.PromptDirectoryPath();
if (!string.IsNullOrWhiteSpace(outputPath)) if (!string.IsNullOrWhiteSpace(path))
OutputPath = outputPath; OutputPath = path;
} }
} }
public void ShowAssetsDirPathPrompt()
{
var path = _dialogManager.PromptDirectoryPath();
if (!string.IsNullOrWhiteSpace(path))
AssetsDirPath = path;
}
public void Confirm() public void Confirm()
{ {
// Prompt the output path if it's not set yet // Prompt the output path if it's not set yet
@@ -138,6 +150,7 @@ public class ExportSetupViewModel : DialogScreen
_settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; _settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
_settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; _settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
_settingsService.LastShouldReuseAssets = ShouldReuseAssets; _settingsService.LastShouldReuseAssets = ShouldReuseAssets;
_settingsService.LastAssetsDirPath = AssetsDirPath;
Close(true); Close(true);
} }
@@ -145,8 +158,10 @@ public class ExportSetupViewModel : DialogScreen
public static class ExportSetupViewModelExtensions public static class ExportSetupViewModelExtensions
{ {
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory, public static ExportSetupViewModel CreateExportSetupViewModel(
Guild guild, IReadOnlyList<Channel> channels) this IViewModelFactory factory,
Guild guild,
IReadOnlyList<Channel> channels)
{ {
var viewModel = factory.CreateExportSetupViewModel(); var viewModel = factory.CreateExportSetupViewModel();
@@ -25,13 +25,15 @@ public static class MessageBoxViewModelExtensions
{ {
public static MessageBoxViewModel CreateMessageBoxViewModel( public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory, this IViewModelFactory factory,
string title, string message, string title,
string? okButtonText, string? cancelButtonText) string message,
string? okButtonText,
string? cancelButtonText)
{ {
var viewModel = factory.CreateMessageBoxViewModel(); var viewModel = factory.CreateMessageBoxViewModel();
viewModel.Title = title; viewModel.Title = title;
viewModel.Message = message; viewModel.Message = message;
viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText); viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText);
viewModel.OkButtonText = okButtonText; viewModel.OkButtonText = okButtonText;
viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText); viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText);
@@ -42,6 +44,7 @@ public static class MessageBoxViewModelExtensions
public static MessageBoxViewModel CreateMessageBoxViewModel( public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory, this IViewModelFactory factory,
string title, string message) => string title,
string message) =>
factory.CreateMessageBoxViewModel(title, message, "CLOSE", null); factory.CreateMessageBoxViewModel(title, message, "CLOSE", null);
} }
@@ -25,7 +25,7 @@ public class DialogManager : IDisposable
void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs) void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
{ {
void OnScreenClosed(object? closeSender, EventArgs args) void OnScreenClosed(object? closeSender, EventArgs closeArgs)
{ {
try try
{ {

Some files were not shown because too many files have changed in this diff Show More