mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 06:39:33 +02:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f5407f032 | |||
| 83a63c2ed7 | |||
| f33a0ac0a7 | |||
| 779b8984d7 | |||
| 3e44a2e88d | |||
| b44e6087be | |||
| 49b96087d3 | |||
| e175c93038 | |||
| 0ee1107638 | |||
| 81a6d363d1 | |||
| 90d71c5b9e | |||
| 5e9c7392db | |||
| c4137cf77e | |||
| 51192425b7 | |||
| 8776a6955b | |||
| c69211797f | |||
| 768fb88c8c | |||
| d4f62387a5 | |||
| bd4cfcdaf6 | |||
| c3abeadf01 | |||
| 86ee9c8d5f | |||
| c99355dec3 | |||
| 11d34109a7 | |||
| 609ca0fc0d | |||
| 6cadb19cc8 | |||
| d02649773e | |||
| 18d9c189b7 | |||
| 3d2d197904 | |||
| bf0d8ab31e | |||
| 30b447d2ae | |||
| 3d047321cf | |||
| 6d787c4071 | |||
| d779ed9bc8 | |||
| 3319b4734d | |||
| e052241713 | |||
| 2b7f1ed4ed | |||
| 03784f0d7c | |||
| e7310274ba | |||
| 4eab781450 | |||
| 6582e26cd3 | |||
| c48899e0b1 | |||
| 41b4e47d0e | |||
| 6bbde4ccdc | |||
| 469a731892 | |||
| d8315c7827 | |||
| a4111c184e | |||
| c945c98f75 | |||
| 31c7ae9312 | |||
| 03c5c1bc5e | |||
| 0ae9062a30 | |||
| 25caf04445 | |||
| 9048557b17 | |||
| faad994332 | |||
| d7537425f8 | |||
| e261edde8b | |||
| 72bb886a5e | |||
| 9428d5ed56 | |||
| b8b1fdb1d1 | |||
| 168b3c2f31 | |||
| 9575fd11b5 | |||
| a332bda07d | |||
| c3f181c9e0 | |||
| b9cd40f211 | |||
| ab933a7240 | |||
| a9fc439cc5 | |||
| d4f5fc5efc | |||
| 505330e117 | |||
| 36db99b5cf | |||
| a4110a362f | |||
| 0ab8bd67c4 | |||
| 31952a109e | |||
| 2e1636e6c9 | |||
| 20e782e6ed | |||
| ebb838bb81 | |||
| 966627f289 | |||
| 4ec00d0ec8 | |||
| 8bc1bcaf16 | |||
| 8f40acdda7 | |||
| bbe18368fa | |||
| 48eece554c | |||
| 7d7f1cc1e5 | |||
| 2a1998b81c | |||
| ac7f08ff3e | |||
| 95c5a43f6e | |||
| 91afded824 | |||
| 0981c51fb9 | |||
| 55209a0517 | |||
| c2c35cf3a3 | |||
| 404542d973 | |||
| f36852a1a2 | |||
| 0e1c3e4c76 | |||
| 83e3289ead | |||
| b51900ec6a | |||
| d1647e8286 | |||
| 95115f3e99 | |||
| 53ff8ba430 | |||
| cbab5fb71c | |||
| 9512729ec2 |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 19 KiB |
@@ -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
@@ -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
@@ -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**
|
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
+1
-1
@@ -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
-4
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
+3
-3
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user