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.
|
||||
|
||||
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
|
||||
|
||||
@@ -84,10 +84,22 @@ from:Tyrrrz | 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
|
||||
```
|
||||
```
|
||||
+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 use message filters](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md)
|
||||
- Export scheduling with CLI:
|
||||
- [Windows](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/scheduling-windows.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)
|
||||
- [Windows](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Scheduling-Windows.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)
|
||||
|
||||
## 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
|
||||
2. Go to the **Advanced** section
|
||||
3. Enable **Developer Mode**
|
||||
4. Right-click on the desired server or channel and click Copy 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**
|
||||
4. Right-click on the desired server or channel and click Copy Server ID or Copy Channel ID
|
||||
@@ -5,51 +5,82 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build container
|
||||
run: docker build -f DiscordChatExporter.Cli.dockerfile .
|
||||
- name: Install Docker Buildx
|
||||
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:
|
||||
if: ${{ github.event_name == 'push' && github.ref_type == 'branch' && github.ref_name == 'master' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- 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
|
||||
run: docker build -f DiscordChatExporter.Cli.dockerfile -t tyrrrz/discordchatexporter:latest .
|
||||
|
||||
- name: Push container
|
||||
run: docker push tyrrrz/discordchatexporter:latest
|
||||
- name: Build & push image
|
||||
run: >
|
||||
docker buildx build
|
||||
--file DiscordChatExporter.Cli.dockerfile
|
||||
--platform linux/amd64,linux/arm64
|
||||
--push
|
||||
--tag tyrrrz/discordchatexporter:latest
|
||||
.
|
||||
|
||||
deploy-stable:
|
||||
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Get release version
|
||||
id: get_version
|
||||
uses: dawidd6/action-get-tag@v1
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- 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
|
||||
run: docker build -f DiscordChatExporter.Cli.dockerfile -t tyrrrz/discordchatexporter:stable -t tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }} .
|
||||
|
||||
- name: Push container (stable)
|
||||
run: docker push tyrrrz/discordchatexporter:stable
|
||||
|
||||
- name: Push container (${{ steps.get_version.outputs.tag }})
|
||||
run: docker push tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }}
|
||||
- name: Build & push image
|
||||
run: >
|
||||
docker buildx build
|
||||
--file DiscordChatExporter.Cli.dockerfile
|
||||
--platform linux/amd64,linux/arm64
|
||||
--push
|
||||
--tag tyrrrz/discordchatexporter:stable
|
||||
--tag tyrrrz/discordchatexporter:${{ github.ref_name }}
|
||||
.
|
||||
+53
-26
@@ -5,6 +5,8 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
test:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||
@@ -23,7 +25,14 @@ jobs:
|
||||
- name: Run tests
|
||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||
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:
|
||||
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
||||
|
||||
@@ -32,9 +41,12 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
package:
|
||||
pack:
|
||||
needs: test
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||
@@ -51,27 +63,36 @@ jobs:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- 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)
|
||||
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)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: DiscordChatExporter.Cli
|
||||
path: DiscordChatExporter.Cli/bin/Publish/
|
||||
path: DiscordChatExporter.Cli/publish/
|
||||
|
||||
- name: Upload artifacts (GUI)
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: DiscordChatExporter
|
||||
path: DiscordChatExporter.Gui/bin/Publish/
|
||||
path: DiscordChatExporter.Gui/publish/
|
||||
|
||||
deploy:
|
||||
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
|
||||
needs: package
|
||||
needs: pack
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts (CLI)
|
||||
@@ -87,43 +108,49 @@ jobs:
|
||||
path: DiscordChatExporter.Gui
|
||||
|
||||
- name: Create package (CLI)
|
||||
run: Compress-Archive -Path DiscordChatExporter.Cli/* -DestinationPath DiscordChatExporter.Cli.zip -Force
|
||||
shell: pwsh
|
||||
run: >
|
||||
Compress-Archive
|
||||
-Path DiscordChatExporter.Cli/*
|
||||
-DestinationPath DiscordChatExporter.Cli.zip
|
||||
-Force
|
||||
|
||||
- name: Create package (GUI)
|
||||
run: Compress-Archive -Path DiscordChatExporter.Gui/* -DestinationPath DiscordChatExporter.zip -Force
|
||||
shell: pwsh
|
||||
run: >
|
||||
Compress-Archive
|
||||
-Path DiscordChatExporter.Gui/*
|
||||
-DestinationPath DiscordChatExporter.zip
|
||||
-Force
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
files: |
|
||||
DiscordChatExporter.Cli.zip
|
||||
DiscordChatExporter.zip
|
||||
body: |
|
||||
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
||||
run: >
|
||||
gh release create "${{ github.ref_name }}"
|
||||
"DiscordChatExporter.Cli.zip"
|
||||
"DiscordChatExporter.zip"
|
||||
--repo "${{ github.event.repository.full_name }}"
|
||||
--title "${{ github.ref_name }}"
|
||||
--notes "[Changelog](${{ github.event.repository.html_url }}/blob/${{ github.ref_name }}/Changelog.md)"
|
||||
--verify-tag
|
||||
|
||||
notify:
|
||||
needs: deploy
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Get release version
|
||||
id: get_version
|
||||
uses: dawidd6/action-get-tag@v1
|
||||
|
||||
- name: Notify Discord
|
||||
uses: satak/webrequest-action@v1.2.4
|
||||
uses: tyrrrz/action-http-request@v1
|
||||
with:
|
||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
method: POST
|
||||
headers: |
|
||||
Content-Type: application/json; charset=UTF-8
|
||||
body: |
|
||||
{
|
||||
"ContentType": "application/json; charset=UTF-8"
|
||||
}
|
||||
payload: |
|
||||
{
|
||||
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
|
||||
"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>"
|
||||
}
|
||||
+47
-4
@@ -1,18 +1,61 @@
|
||||
# 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)
|
||||
|
||||
- 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 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.
|
||||
- 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 some embed images were rendered using their actual URLs instead of Discord proxy URLs.
|
||||
- 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.
|
||||
- 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.
|
||||
- Minor cosmetic improvements.
|
||||
- CLI changes:
|
||||
@@ -20,7 +63,7 @@
|
||||
- HTML format changes:
|
||||
- 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.
|
||||
- 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 messages triggered by app interactions were not rendered as replies.
|
||||
- Fixed an issue where server cross-posts were not rendered correctly.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>2.38</Version>
|
||||
<Version>2.40.1</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.0.1" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.10.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" Version="1.0.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.11.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.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.UserSecrets" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="ReflectionMagic" Version="4.1.0" />
|
||||
<PackageReference Include="xunit" Version="2.4.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
|
||||
<PackageReference Include="coverlet.collector" Version="3.2.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" />
|
||||
<PackageReference Include="ReflectionMagic" Version="5.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.5.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.0" PrivateAssets="all" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -48,23 +48,22 @@ public static class ExportWrapper
|
||||
var fileName = channelId.ToString() + '.' + format.GetFileExtension();
|
||||
var filePath = Path.Combine(DirPath, fileName);
|
||||
|
||||
// Lock separately for each channel and format
|
||||
using (await Locker.LockAsync(filePath))
|
||||
{
|
||||
// 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());
|
||||
}
|
||||
using var _ = await Locker.LockAsync(filePath);
|
||||
using var console = 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(
|
||||
@@ -94,14 +93,13 @@ public static class ExportWrapper
|
||||
|
||||
public static async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
|
||||
{
|
||||
var message = (await GetMessagesAsHtmlAsync(channelId))
|
||||
.SingleOrDefault(e =>
|
||||
string.Equals(
|
||||
e.GetAttribute("data-message-id"),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
);
|
||||
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
|
||||
string.Equals(
|
||||
e.GetAttribute("data-message-id"),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
);
|
||||
|
||||
if (message is null)
|
||||
{
|
||||
@@ -115,14 +113,13 @@ public static class ExportWrapper
|
||||
|
||||
public static async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
|
||||
{
|
||||
var message = (await GetMessagesAsJsonAsync(channelId))
|
||||
.SingleOrDefault(j =>
|
||||
string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
);
|
||||
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
|
||||
string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
);
|
||||
|
||||
if (message.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
|
||||
@@ -8,14 +8,14 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class CsvContentSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task Messages_are_exported_correctly()
|
||||
public async Task I_can_export_a_channel_in_the_CSV_format()
|
||||
{
|
||||
// Act
|
||||
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
||||
|
||||
// Assert
|
||||
document.Should().ContainAll(
|
||||
"Tyrrrz#5447",
|
||||
"tyrrrz",
|
||||
"Hello world",
|
||||
"Goodbye world",
|
||||
"Foo bar",
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class DateRangeSpecs
|
||||
{
|
||||
[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
|
||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||
@@ -61,7 +61,7 @@ public class DateRangeSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||
@@ -103,7 +103,7 @@ public class DateRangeSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class FilterSpecs
|
||||
{
|
||||
[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
|
||||
using var file = TempFile.Create();
|
||||
@@ -42,7 +42,7 @@ public class FilterSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
using var file = TempFile.Create();
|
||||
@@ -64,11 +64,11 @@ public class FilterSpecs
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
||||
.Should()
|
||||
.AllBe("Tyrrrz");
|
||||
.AllBe("tyrrrz");
|
||||
}
|
||||
|
||||
[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
|
||||
using var file = TempFile.Create();
|
||||
@@ -94,7 +94,7 @@ public class FilterSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
using var file = TempFile.Create();
|
||||
@@ -120,7 +120,7 @@ public class FilterSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
using var file = TempFile.Create();
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class HtmlAttachmentSpecs
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -36,7 +36,7 @@ public class HtmlAttachmentSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -57,7 +57,7 @@ public class HtmlAttachmentSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -77,7 +77,7 @@ public class HtmlAttachmentSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -11,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class HtmlContentSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task Messages_are_exported_correctly()
|
||||
public async Task I_can_export_a_channel_in_the_HTML_format()
|
||||
{
|
||||
// Act
|
||||
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
||||
@@ -39,21 +38,4 @@ public class HtmlContentSpecs
|
||||
"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 DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -11,7 +12,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class HtmlEmbedSpecs
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -32,7 +33,7 @@ public class HtmlEmbedSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -46,13 +47,14 @@ public class HtmlEmbedSpecs
|
||||
message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.Where(s => s?.EndsWith("i.redd.it/f8w05ja8s4e61.png") ?? false)
|
||||
.WhereNotNull()
|
||||
.Where(s => s.EndsWith("f8w05ja8s4e61.png"))
|
||||
.Should()
|
||||
.ContainSingle();
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -68,7 +70,26 @@ public class HtmlEmbedSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -80,13 +101,14 @@ public class HtmlEmbedSpecs
|
||||
message
|
||||
.QuerySelectorAll("source")
|
||||
.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()
|
||||
.ContainSingle();
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -100,7 +122,7 @@ public class HtmlEmbedSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -116,7 +138,7 @@ public class HtmlEmbedSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -132,7 +154,7 @@ public class HtmlEmbedSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -158,7 +180,7 @@ public class HtmlEmbedSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class HtmlGroupingSpecs
|
||||
{
|
||||
[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
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class HtmlMarkdownSpecs
|
||||
{
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -36,7 +36,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -60,7 +60,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -84,7 +84,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -108,7 +108,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -132,7 +132,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -156,7 +156,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -180,7 +180,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
@@ -204,7 +204,7 @@ public class HtmlMarkdownSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2));
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class HtmlMentionSpecs
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -20,11 +20,11 @@ public class HtmlMentionSpecs
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("User mention: @Tyrrrz");
|
||||
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
||||
message.InnerHtml.Should().Contain("tyrrrz");
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -37,7 +37,7 @@ public class HtmlMentionSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -50,7 +50,7 @@ public class HtmlMentionSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class HtmlReplySpecs
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -24,7 +24,7 @@ public class HtmlReplySpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -42,7 +42,7 @@ public class HtmlReplySpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -58,7 +58,7 @@ public class HtmlReplySpecs
|
||||
}
|
||||
|
||||
[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
|
||||
|
||||
@@ -71,4 +71,21 @@ public class HtmlReplySpecs
|
||||
// Assert
|
||||
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
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -19,11 +19,11 @@ public class HtmlStickerSpecs
|
||||
|
||||
// Assert
|
||||
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]
|
||||
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
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
@@ -33,6 +33,6 @@ public class HtmlStickerSpecs
|
||||
|
||||
// Assert
|
||||
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
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -23,15 +23,16 @@ public class JsonAttachmentSpecs
|
||||
|
||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
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"
|
||||
);
|
||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
|
||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
|
||||
attachments[0].GetProperty("fileName").GetString().Should().Be("Test.txt");
|
||||
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -44,15 +45,16 @@ public class JsonAttachmentSpecs
|
||||
|
||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
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"
|
||||
);
|
||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
|
||||
attachments[0].GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
||||
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -65,15 +67,16 @@ public class JsonAttachmentSpecs
|
||||
|
||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
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"
|
||||
);
|
||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
|
||||
attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
||||
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -86,10 +89,11 @@ public class JsonAttachmentSpecs
|
||||
|
||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
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"
|
||||
);
|
||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
||||
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
|
||||
attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
||||
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class JsonContentSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task Messages_are_exported_correctly()
|
||||
public async Task I_can_export_a_channel_in_the_JSON_format()
|
||||
{
|
||||
// Act
|
||||
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class JsonEmbedSpecs
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class JsonMentionSpecs
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -30,7 +30,7 @@ public class JsonMentionSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -43,7 +43,7 @@ public class JsonMentionSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -56,7 +56,7 @@ public class JsonMentionSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class JsonStickerSpecs
|
||||
{
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -26,12 +26,12 @@ public class JsonStickerSpecs
|
||||
|
||||
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
|
||||
sticker.GetProperty("name").GetString().Should().Be("rock");
|
||||
sticker.GetProperty("format").GetString().Should().Be("PngAnimated");
|
||||
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/904215665597120572.png");
|
||||
sticker.GetProperty("format").GetString().Should().Be("Apng");
|
||||
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://cdn.discordapp.com/stickers/904215665597120572.png");
|
||||
}
|
||||
|
||||
[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
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
@@ -48,6 +48,6 @@ public class JsonStickerSpecs
|
||||
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
|
||||
sticker.GetProperty("name").GetString().Should().Be("Yikes");
|
||||
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
|
||||
{
|
||||
[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
|
||||
using var dir = TempDir.Create();
|
||||
@@ -37,7 +37,7 @@ public class PartitioningSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
using var dir = TempDir.Create();
|
||||
|
||||
@@ -8,14 +8,14 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class PlainTextContentSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task Messages_are_exported_correctly()
|
||||
public async Task I_can_export_a_channel_in_the_TXT_format()
|
||||
{
|
||||
// Act
|
||||
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
||||
|
||||
// Assert
|
||||
document.Should().ContainAll(
|
||||
"Tyrrrz#5447",
|
||||
"tyrrrz",
|
||||
"Hello world",
|
||||
"Goodbye world",
|
||||
"Foo bar",
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
public class SelfContainedSpecs
|
||||
{
|
||||
[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
|
||||
using var dir = TempDir.Create();
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
# -- 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
|
||||
|
||||
COPY favicon.ico ./
|
||||
COPY NuGet.config ./
|
||||
COPY Directory.Build.props ./
|
||||
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core
|
||||
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli
|
||||
COPY favicon.ico .
|
||||
COPY NuGet.config .
|
||||
COPY Directory.Build.props .
|
||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||
|
||||
# Publish a self-contained assembly so we can use a slimmer runtime image
|
||||
RUN dotnet publish DiscordChatExporter.Cli \
|
||||
--configuration Release \
|
||||
--self-contained \
|
||||
--use-current-runtime \
|
||||
--configuration Release \
|
||||
--output ./publish
|
||||
--arch $TARGETARCH \
|
||||
--output publish/
|
||||
|
||||
# -- 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
|
||||
# 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.
|
||||
# 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
|
||||
RUN adduser \
|
||||
--disabled-password \
|
||||
--no-create-home \
|
||||
dce
|
||||
|
||||
RUN adduser --disabled-password --no-create-home dce
|
||||
USER dce
|
||||
|
||||
COPY --from=build /build/publish /opt/dce
|
||||
|
||||
# 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.
|
||||
# This directory is exposed to the user for mounting purposes, so it's important that it always
|
||||
# stays the same for backwards compatibility.
|
||||
WORKDIR /out
|
||||
|
||||
# Add the app directory to PATH so that it's easier to debug using a shell
|
||||
ENV PATH="$PATH:/opt/dce"
|
||||
ENTRYPOINT ["DiscordChatExporter.Cli"]
|
||||
COPY --from=build /build/publish /opt/dce
|
||||
ENTRYPOINT ["/opt/dce/DiscordChatExporter.Cli"]
|
||||
+1
-1
@@ -7,7 +7,7 @@ using DiscordChatExporter.Core.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
public abstract class TokenCommandBase : ICommand
|
||||
public abstract class DiscordCommandBase : ICommand
|
||||
{
|
||||
[CommandOption(
|
||||
"token",
|
||||
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
@@ -20,7 +21,7 @@ using Gress;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
public abstract class ExportCommandBase : TokenCommandBase
|
||||
public abstract class ExportCommandBase : DiscordCommandBase
|
||||
{
|
||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
||||
|
||||
@@ -29,8 +30,8 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
'o',
|
||||
Description =
|
||||
"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 should end with a slash to avoid ambiguity. " +
|
||||
"Directory path must 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."
|
||||
)]
|
||||
public string OutputPath
|
||||
@@ -64,13 +65,16 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
"partition",
|
||||
'p',
|
||||
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;
|
||||
|
||||
[CommandOption(
|
||||
"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;
|
||||
|
||||
@@ -98,6 +102,22 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
)]
|
||||
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(
|
||||
"dateformat",
|
||||
Description = "Format used when writing dates."
|
||||
@@ -106,7 +126,10 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
|
||||
[CommandOption(
|
||||
"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; }
|
||||
|
||||
@@ -115,7 +138,7 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
|
||||
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
|
||||
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.
|
||||
// Output path must either be a directory, or contain template tokens.
|
||||
// Assets directory can only be specified if the download assets option is set
|
||||
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/917
|
||||
var isValidOutputPath =
|
||||
@@ -144,10 +175,10 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
);
|
||||
}
|
||||
|
||||
// Export
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var errors = new ConcurrentDictionary<Channel, string>();
|
||||
|
||||
// Export
|
||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
||||
await console.CreateProgressTicker().StartAsync(async progressContext =>
|
||||
{
|
||||
@@ -163,7 +194,7 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
try
|
||||
{
|
||||
await progressContext.StartTaskAsync(
|
||||
$"{channel.Category.Name} / {channel.Name}",
|
||||
$"{channel.Category} / {channel.Name}",
|
||||
async progress =>
|
||||
{
|
||||
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
||||
@@ -172,6 +203,7 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
guild,
|
||||
channel,
|
||||
OutputPath,
|
||||
AssetsDirPath,
|
||||
ExportFormat,
|
||||
After,
|
||||
Before,
|
||||
@@ -199,7 +231,7 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
);
|
||||
});
|
||||
|
||||
// Print result
|
||||
// Print the result
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
@@ -214,28 +246,26 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
await console.Error.WriteLineAsync(
|
||||
$"Failed to export {errors.Count} channel(s):"
|
||||
);
|
||||
}
|
||||
|
||||
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))
|
||||
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.
|
||||
// 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)
|
||||
{
|
||||
throw new CommandException("Export failed.");
|
||||
}
|
||||
}
|
||||
|
||||
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)...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
var guildChannelMap = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
|
||||
foreach (var channelId in channelIds)
|
||||
{
|
||||
@@ -255,17 +285,17 @@ public abstract class ExportCommandBase : TokenCommandBase
|
||||
if (channel.Kind == ChannelKind.GuildCategory)
|
||||
{
|
||||
var guildChannels =
|
||||
guildChannelMap.GetValueOrDefault(channel.GuildId) ??
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId) ??
|
||||
await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
|
||||
foreach (var guildChannel in guildChannels)
|
||||
{
|
||||
if (guildChannel.Category.Id == channel.Id)
|
||||
if (guildChannel.Parent?.Id == channel.Id)
|
||||
channels.Add(guildChannel);
|
||||
}
|
||||
|
||||
// Cache the guild channels to avoid redundant work
|
||||
guildChannelMap[channel.GuildId] = guildChannels;
|
||||
channelsByGuild[channel.GuildId] = guildChannels;
|
||||
}
|
||||
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.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("exportall", Description = "Export all accessible channels.")]
|
||||
[Command("exportall", Description = "Exports all accessible channels.")]
|
||||
public class ExportAllCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption(
|
||||
"include-dm",
|
||||
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)
|
||||
{
|
||||
@@ -23,16 +49,62 @@ public class ExportAllCommand : ExportCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var channels = new List<Channel>();
|
||||
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
|
||||
// Pull from the API
|
||||
if (string.IsNullOrWhiteSpace(DataPackageFilePath))
|
||||
{
|
||||
// Skip DMs if instructed to
|
||||
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
|
||||
continue;
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
|
||||
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
|
||||
channels.Add(channel);
|
||||
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,16 @@ using DiscordChatExporter.Core.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("export", Description = "Export one or multiple channels.")]
|
||||
[Command("export", Description = "Exports one or multiple channels.")]
|
||||
public class ExportChannelsCommand : ExportCommandBase
|
||||
{
|
||||
// TODO: change this to plural (breaking change)
|
||||
[CommandOption(
|
||||
"channel",
|
||||
'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; }
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
@@ -8,7 +7,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
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 override async ValueTask ExecuteAsync(IConsole console)
|
||||
@@ -18,10 +17,7 @@ public class ExportDirectMessagesCommand : ExportCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.ToArray();
|
||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
||||
|
||||
await base.ExecuteAsync(console, channels);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
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
|
||||
{
|
||||
[CommandOption(
|
||||
@@ -19,6 +19,12 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
)]
|
||||
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)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
@@ -29,6 +35,7 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.Where(c => IncludeVoiceChannels || !c.Kind.IsVoice())
|
||||
.ToArray();
|
||||
|
||||
await base.ExecuteAsync(console, channels);
|
||||
|
||||
@@ -11,7 +11,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||
public class GetChannelsCommand : TokenCommandBase
|
||||
public class GetChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
[CommandOption(
|
||||
"guild",
|
||||
@@ -20,21 +20,36 @@ public class GetChannelsCommand : TokenCommandBase
|
||||
)]
|
||||
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)
|
||||
{
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.OrderBy(c => c.Category.Position)
|
||||
.OrderBy(c => c.Parent?.Position)
|
||||
.ThenBy(c => c.Name)
|
||||
.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)
|
||||
{
|
||||
// Channel ID
|
||||
await console.Output.WriteAsync(
|
||||
channel.Id.ToString().PadRight(18, ' ')
|
||||
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
@@ -43,7 +58,40 @@ public class GetChannelsCommand : TokenCommandBase
|
||||
|
||||
// Channel category / name
|
||||
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;
|
||||
|
||||
[Command("dm", Description = "Get the list of direct message channels.")]
|
||||
public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||
[Command("dm", Description = "Gets the list of all direct message channels.")]
|
||||
public class GetDirectChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -22,11 +22,16 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||
.ThenBy(c => c.Name)
|
||||
.ToArray();
|
||||
|
||||
var channelIdMaxLength = channels
|
||||
.Select(c => c.Id.ToString().Length)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// Channel ID
|
||||
await console.Output.WriteAsync(
|
||||
channel.Id.ToString().PadRight(18, ' ')
|
||||
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
@@ -35,7 +40,7 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||
|
||||
// Channel category / name
|
||||
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;
|
||||
|
||||
[Command("guilds", Description = "Get the list of accessible guilds.")]
|
||||
public class GetGuildsCommand : TokenCommandBase
|
||||
[Command("guilds", Description = "Gets the list of accessible guilds.")]
|
||||
public class GetGuildsCommand : DiscordCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -19,13 +19,19 @@ public class GetGuildsCommand : TokenCommandBase
|
||||
var guilds = (await Discord.GetUserGuildsAsync(cancellationToken))
|
||||
// Show direct messages first
|
||||
.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)
|
||||
{
|
||||
// Guild ID
|
||||
await console.Output.WriteAsync(
|
||||
guild.Id.ToString().PadRight(18, ' ')
|
||||
guild.Id.ToString().PadRight(guildIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
|
||||
@@ -6,7 +6,7 @@ using CliFx.Infrastructure;
|
||||
|
||||
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 ValueTask ExecuteAsync(IConsole console)
|
||||
@@ -41,25 +41,13 @@ public class GuideCommand : ICommand
|
||||
|
||||
// Guild or channel ID
|
||||
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(" 2. Open Settings");
|
||||
console.Output.WriteLine(" 3. Go to Advanced section");
|
||||
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();
|
||||
|
||||
// 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(" 5. Right-click on the desired guild or channel and click Copy Server ID or Copy Channel ID");
|
||||
console.Output.WriteLine();
|
||||
|
||||
// Docs link
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.1" />
|
||||
<PackageReference Include="FuckRussia" Version="1.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.46.0" />
|
||||
<PackageReference Include="Gress" Version="2.0.1" />
|
||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.4.0" PrivateAssets="all" />
|
||||
<PackageReference Include="CliFx" Version="2.3.4" />
|
||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.47.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
@@ -20,11 +19,26 @@ public partial record Attachment(
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -12,45 +11,44 @@ public partial record Channel(
|
||||
Snowflake Id,
|
||||
ChannelKind Kind,
|
||||
Snowflake GuildId,
|
||||
ChannelCategory Category,
|
||||
Channel? Parent,
|
||||
string Name,
|
||||
int? Position,
|
||||
string? IconUrl,
|
||||
string? Topic,
|
||||
bool IsActive,
|
||||
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
|
||||
{
|
||||
private static ChannelCategory GetFallbackCategory(ChannelKind channelKind) => new(
|
||||
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)
|
||||
public static Channel Parse(JsonElement json, Channel? parent = null, int? positionHint = null)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
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 =
|
||||
// Guild channel
|
||||
@@ -60,7 +58,7 @@ public partial record Channel
|
||||
json.GetPropertyOrNull("recipients")?
|
||||
.EnumerateArrayOrNull()?
|
||||
.Select(User.Parse)
|
||||
.Select(u => u.Name)
|
||||
.Select(u => u.DisplayName)
|
||||
.Pipe(s => string.Join(", ", s)) ??
|
||||
|
||||
// Fallback
|
||||
@@ -70,11 +68,19 @@ public partial record Channel
|
||||
positionHint ??
|
||||
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||
|
||||
// Only available on group DMs
|
||||
var iconUrl = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h));
|
||||
// Icons can only be set for group DM channels
|
||||
var iconUrl = json
|
||||
.GetPropertyOrNull("icon")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
|
||||
|
||||
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
||||
|
||||
var isActive = !json
|
||||
.GetPropertyOrNull("thread_metadata")?
|
||||
.GetPropertyOrNull("archived")?
|
||||
.GetBooleanOrNull() ?? true;
|
||||
|
||||
var lastMessageId = json
|
||||
.GetPropertyOrNull("last_message_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
@@ -83,12 +89,13 @@ public partial record Channel
|
||||
return new Channel(
|
||||
id,
|
||||
kind,
|
||||
guildId ?? Guild.DirectMessages.Id,
|
||||
category ?? GetFallbackCategory(kind),
|
||||
guildId,
|
||||
parent,
|
||||
name,
|
||||
position,
|
||||
iconUrl,
|
||||
topic,
|
||||
isActive,
|
||||
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;
|
||||
|
||||
// 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
|
||||
{
|
||||
GuildTextChat = 0,
|
||||
@@ -16,4 +15,16 @@ public enum ChannelKind
|
||||
GuildStageVoice = 13,
|
||||
GuildDirectory = 14,
|
||||
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;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var color = json
|
||||
.GetPropertyOrNull("color")?
|
||||
@@ -70,7 +70,7 @@ public partial record Embed
|
||||
// with this by merging related embeds at the end of the message parsing process.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
|
||||
var images =
|
||||
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ??
|
||||
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).ToSingletonEnumerable().ToArray() ??
|
||||
Array.Empty<EmbedImage>();
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -9,37 +7,6 @@ public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||
|
||||
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)
|
||||
{
|
||||
if (embed.Kind != EmbedKind.Video)
|
||||
@@ -48,8 +15,8 @@ public partial record YouTubeVideoEmbedProjection
|
||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||
return null;
|
||||
|
||||
var videoId = TryParseVideoId(embed.Url);
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
var videoId = YoutubeExplode.Videos.VideoId.TryParse(embed.Url);
|
||||
if (videoId is null)
|
||||
return null;
|
||||
|
||||
return new YouTubeVideoEmbedProjection(videoId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
@@ -24,54 +24,32 @@ 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)
|
||||
{
|
||||
// Custom emoji
|
||||
if (id is not null)
|
||||
return GetImageUrl(id.Value, isAnimated);
|
||||
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
|
||||
|
||||
// Standard emoji
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
return GetImageUrl(name);
|
||||
return ImageCdn.GetStandardEmojiUrl(name);
|
||||
|
||||
// Either ID or name should be set
|
||||
throw new ApplicationException("Emoji has neither ID nor name set.");
|
||||
throw new InvalidOperationException("Either the emoji ID or name should be provided.");
|
||||
}
|
||||
|
||||
public static Emoji Parse(JsonElement json)
|
||||
{
|
||||
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 imageUrl = GetImageUrl(id, name, isAnimated);
|
||||
|
||||
return new Emoji(
|
||||
id,
|
||||
// Name may be missing if it's an emoji inside a reaction
|
||||
name ?? "<unknown emoji>",
|
||||
name,
|
||||
isAnimated,
|
||||
imageUrl
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
@@ -9,32 +8,24 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||
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(
|
||||
Snowflake.Zero,
|
||||
"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)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
var name = json.GetProperty("name").GetNonNullString();
|
||||
|
||||
var iconUrl =
|
||||
json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetIconUrl(id, h)) ??
|
||||
GetDefaultIconUrl();
|
||||
json
|
||||
.GetPropertyOrNull("icon")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
|
||||
ImageCdn.GetFallbackUserAvatarUrl();
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/invite#invite-object
|
||||
public record Invite(
|
||||
string Code,
|
||||
Guild? Guild,
|
||||
Guild Guild,
|
||||
Channel? Channel)
|
||||
{
|
||||
public static string? TryGetCodeFromUrl(string url) =>
|
||||
@@ -17,7 +17,7 @@ public record Invite(
|
||||
public static Invite Parse(JsonElement json)
|
||||
{
|
||||
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));
|
||||
|
||||
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
|
||||
public partial record Member(
|
||||
User User,
|
||||
string Nick,
|
||||
string? DisplayName,
|
||||
string? AvatarUrl,
|
||||
IReadOnlyList<Snowflake> RoleIds) : IHasId
|
||||
{
|
||||
public Snowflake Id => User.Id;
|
||||
@@ -19,10 +20,13 @@ 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 nick = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull();
|
||||
var displayName = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull();
|
||||
|
||||
var roleIds = json
|
||||
.GetPropertyOrNull("roles")?
|
||||
@@ -31,9 +35,17 @@ public partial record Member
|
||||
.Select(Snowflake.Parse)
|
||||
.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(
|
||||
user,
|
||||
nick ?? user.Name,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
roleIds
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,15 +108,16 @@ public partial record Message
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
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 timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var callEndedTimestamp = json
|
||||
.GetPropertyOrNull("call")?
|
||||
.GetPropertyOrNull("ended_timestamp")?
|
||||
.GetDateTimeOffset();
|
||||
.GetDateTimeOffsetOrNull();
|
||||
|
||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
|
||||
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
|
||||
|
||||
@@ -17,5 +17,5 @@ public enum MessageKind
|
||||
|
||||
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
|
||||
.GetPropertyOrNull("color")?
|
||||
.GetInt32()
|
||||
.GetInt32OrNull()?
|
||||
.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha()
|
||||
.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 JsonExtensions.Reading;
|
||||
|
||||
@@ -7,18 +9,19 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/sticker#sticker-resource
|
||||
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)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
var name = json.GetProperty("name").GetNonNullString();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
public enum StickerFormat
|
||||
{
|
||||
Png = 1,
|
||||
PngAnimated = 2,
|
||||
Apng = 2,
|
||||
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.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
@@ -10,40 +9,51 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
public partial record User(
|
||||
Snowflake Id,
|
||||
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 DisplayName,
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
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 displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name;
|
||||
|
||||
var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6);
|
||||
|
||||
var avatarUrl =
|
||||
json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull()?.Pipe(h => GetAvatarUrl(id, h)) ??
|
||||
GetDefaultAvatarUrl(discriminator);
|
||||
json
|
||||
.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)
|
||||
{
|
||||
using var response = await GetResponseAsync(url, cancellationToken);
|
||||
|
||||
return response.IsSuccessStatusCode
|
||||
? await response.Content.ReadAsJsonAsync(cancellationToken)
|
||||
: 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(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
yield return Guild.DirectMessages;
|
||||
|
||||
var currentAfter = Snowflake.Zero;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
@@ -168,8 +174,9 @@ public class DiscordClient
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
|
||||
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;
|
||||
|
||||
currentAfter = guild.Id;
|
||||
@@ -206,34 +213,114 @@ public class DiscordClient
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels", cancellationToken);
|
||||
|
||||
var responseOrdered = response
|
||||
var channelsJson = response
|
||||
.EnumerateArray()
|
||||
.OrderBy(j => j.GetProperty("position").GetInt32())
|
||||
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
|
||||
.ToArray();
|
||||
|
||||
var categories = responseOrdered
|
||||
.Where(j => j.GetProperty("type").GetInt32() == (int) ChannelKind.GuildCategory)
|
||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
||||
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
||||
var parentsById = channelsJson
|
||||
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory)
|
||||
.Select((j, i) => Channel.Parse(j, null, i + 1))
|
||||
.ToDictionary(j => j.Id);
|
||||
|
||||
// Discord positions are not deterministic, so we need to normalize them
|
||||
// because the user may refer to the channel position via file name template.
|
||||
// Discord channel positions are relative, so we need to normalize them
|
||||
// so that the user may refer to them more easily in file name templates.
|
||||
var position = 0;
|
||||
|
||||
foreach (var channelJson in responseOrdered)
|
||||
foreach (var channelJson in channelsJson)
|
||||
{
|
||||
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetNonWhiteSpaceStringOrNull();
|
||||
|
||||
var category = !string.IsNullOrWhiteSpace(parentId)
|
||||
? categories.GetValueOrDefault(parentId)
|
||||
: null;
|
||||
|
||||
var channel = Channel.Parse(channelJson, category, position);
|
||||
var parent = channelJson
|
||||
.GetPropertyOrNull("parent_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse)
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
yield return Channel.Parse(channelJson, parent, 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;
|
||||
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
|
||||
|
||||
foreach (var roleJson in response.EnumerateArray())
|
||||
yield return Role.Parse(roleJson);
|
||||
}
|
||||
@@ -260,10 +346,10 @@ public class DiscordClient
|
||||
return null;
|
||||
|
||||
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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -271,36 +357,22 @@ public class DiscordClient
|
||||
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(
|
||||
Snowflake channelId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
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
|
||||
? await GetChannelCategoryAsync(parentId.Value, cancellationToken)
|
||||
var parent = parentId is not null
|
||||
? await GetChannelAsync(parentId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response, category);
|
||||
return Channel.Parse(response, parent);
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||
@@ -325,18 +397,18 @@ public class DiscordClient
|
||||
IProgress<Percentage>? progress = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Get the last message in the specified range.
|
||||
// This snapshots the boundaries, which means that messages posted after the export started
|
||||
// will not appear in the output.
|
||||
// Additionally, it provides the date of the last message, which is used to calculate progress.
|
||||
// Get the last message in the specified range, so we can later calculate the
|
||||
// progress based on the difference between message timestamps.
|
||||
// This also snapshots the boundaries, which means that messages posted after
|
||||
// the export started will not appear in the output.
|
||||
var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken);
|
||||
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
|
||||
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 currentAfter = after ?? Snowflake.Zero;
|
||||
|
||||
var currentAfter = after ?? Snowflake.Zero;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
@@ -350,7 +422,8 @@ public class DiscordClient
|
||||
var messages = response
|
||||
.EnumerateArray()
|
||||
.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();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
@@ -361,11 +434,11 @@ public class DiscordClient
|
||||
{
|
||||
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)
|
||||
yield break;
|
||||
|
||||
// Report progress based on the duration of exported messages divided by total
|
||||
// Report progress based on timestamps
|
||||
if (progress is not null)
|
||||
{
|
||||
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
||||
@@ -373,7 +446,7 @@ public class DiscordClient
|
||||
|
||||
progress.Report(Percentage.FromFraction(
|
||||
// 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
|
||||
? exportedDuration / totalDuration
|
||||
: 1
|
||||
|
||||
@@ -29,15 +29,11 @@ public partial record struct Snowflake
|
||||
|
||||
// As number
|
||||
if (ulong.TryParse(str, NumberStyles.None, formatProvider, out var value))
|
||||
{
|
||||
return new Snowflake(value);
|
||||
}
|
||||
|
||||
// As date
|
||||
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var instant))
|
||||
{
|
||||
return FromDate(instant);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -59,4 +55,8 @@ public partial record struct Snowflake : IComparable<Snowflake>, IComparable
|
||||
|
||||
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">
|
||||
|
||||
<PropertyGroup>
|
||||
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" Version="6.1.1" />
|
||||
<PackageReference Include="Gress" Version="2.0.1" />
|
||||
<PackageReference Include="AsyncKeyedLock" Version="6.2.1" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="7.2.3" />
|
||||
<PackageReference Include="RazorBlade" Version="0.4.2" />
|
||||
<PackageReference Include="Polly" Version="7.2.4" />
|
||||
<PackageReference Include="RazorBlade" Version="0.4.3" />
|
||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.13.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<RazorBlade Include="**\*.cshtml" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -18,13 +18,20 @@ public class ChannelExporter
|
||||
IProgress<Percentage>? progress = null,
|
||||
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
|
||||
var context = new ExportContext(_discord, request);
|
||||
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
||||
|
||||
// Export messages
|
||||
await using var messageExporter = new MessageExporter(context);
|
||||
|
||||
await foreach (var message in _discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
@@ -34,7 +41,7 @@ public class ChannelExporter
|
||||
{
|
||||
// Resolve members for referenced users
|
||||
foreach (var user in message.GetReferencedUsers())
|
||||
await context.PopulateMemberAsync(user.Id, cancellationToken);
|
||||
await context.PopulateMemberAsync(user, cancellationToken);
|
||||
|
||||
// Export the message
|
||||
if (request.MessageFilter.IsMatch(message))
|
||||
|
||||
@@ -89,11 +89,15 @@ internal partial class CsvMessageWriter : MessageWriter
|
||||
// Message content
|
||||
if (message.Kind.IsSystemNotification())
|
||||
{
|
||||
await _writer.WriteAsync(CsvEncode(message.GetFallbackContent()));
|
||||
await _writer.WriteAsync(CsvEncode(
|
||||
message.GetFallbackContent()
|
||||
));
|
||||
}
|
||||
else
|
||||
{
|
||||
await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content, cancellationToken)));
|
||||
await _writer.WriteAsync(CsvEncode(
|
||||
await FormatMarkdownAsync(message.Content, cancellationToken)
|
||||
));
|
||||
}
|
||||
|
||||
await _writer.WriteAsync(',');
|
||||
|
||||
@@ -38,51 +38,49 @@ internal partial class ExportAssetDownloader
|
||||
var fileName = GetFileNameFromUrl(url);
|
||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||
|
||||
using (await Locker.LockAsync(filePath, cancellationToken))
|
||||
{
|
||||
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||
return cachedFilePath;
|
||||
using var _ = await Locker.LockAsync(filePath, cancellationToken);
|
||||
|
||||
// Reuse existing files if we're allowed to
|
||||
if (_reuse && File.Exists(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.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||
// Updating file dates is not a critical task, so we'll just
|
||||
// ignore exceptions thrown here.
|
||||
}
|
||||
});
|
||||
if (_pathCache.TryGetValue(url, out var cachedFilePath))
|
||||
return cachedFilePath;
|
||||
|
||||
// Reuse existing files if we're allowed to
|
||||
if (_reuse && File.Exists(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);
|
||||
|
||||
// Try to extract file name from URL
|
||||
// Try to extract the file name from URL
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
// 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);
|
||||
|
||||
// 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)
|
||||
{
|
||||
fileNameWithoutExtension = fileName;
|
||||
|
||||
@@ -20,6 +20,7 @@ internal class ExportContext
|
||||
private readonly ExportAssetDownloader _assetDownloader;
|
||||
|
||||
public DiscordClient Discord { get; }
|
||||
|
||||
public ExportRequest Request { get; }
|
||||
|
||||
public ExportContext(DiscordClient discord,
|
||||
@@ -29,7 +30,7 @@ internal class ExportContext
|
||||
Request = request;
|
||||
|
||||
_assetDownloader = new ExportAssetDownloader(
|
||||
request.OutputAssetsDirPath,
|
||||
request.AssetsDirPath,
|
||||
request.ShouldReuseAssets
|
||||
);
|
||||
}
|
||||
@@ -43,18 +44,38 @@ internal class ExportContext
|
||||
_roles[role.Id] = role;
|
||||
}
|
||||
|
||||
// Because members are not pulled in bulk, we need to populate them on demand
|
||||
public async ValueTask PopulateMemberAsync(Snowflake id, CancellationToken cancellationToken = default)
|
||||
// Because members cannot be pulled in bulk, we need to populate them on demand
|
||||
private async ValueTask PopulateMemberAsync(
|
||||
Snowflake id,
|
||||
User? fallbackUser,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_members.ContainsKey(id))
|
||||
return;
|
||||
|
||||
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
|
||||
_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
|
||||
{
|
||||
"unix" => instant.ToUnixTimeSeconds().ToString(),
|
||||
@@ -68,22 +89,17 @@ internal class ExportContext
|
||||
|
||||
public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id);
|
||||
|
||||
public Color? TryGetUserColor(Snowflake id)
|
||||
{
|
||||
var member = TryGetMember(id);
|
||||
public IReadOnlyList<Role> GetUserRoles(Snowflake id) => TryGetMember(id)?
|
||||
.RoleIds
|
||||
.Select(TryGetRole)
|
||||
.WhereNotNull()
|
||||
.OrderByDescending(r => r.Position)
|
||||
.ToArray() ?? Array.Empty<Role>();
|
||||
|
||||
var memberRoles = member?
|
||||
.RoleIds
|
||||
.Select(TryGetRole)
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
return memberRoles?
|
||||
.Where(r => r.Color is not null)
|
||||
.OrderByDescending(r => r.Position)
|
||||
.Select(r => r.Color)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id)
|
||||
.Where(r => r.Color is not null)
|
||||
.Select(r => r.Color)
|
||||
.FirstOrDefault();
|
||||
|
||||
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -93,34 +109,32 @@ internal class ExportContext
|
||||
try
|
||||
{
|
||||
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.
|
||||
// Base directory path may be null if the file is stored at the root or relative to working directory.
|
||||
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath)
|
||||
? Path.GetRelativePath(Request.OutputBaseDirPath, filePath)
|
||||
: filePath;
|
||||
// Prefer relative paths so that the output files can be copied around without breaking references.
|
||||
// If the asset directory is outside of the export directory, use an absolute path instead.
|
||||
var optimalFilePath =
|
||||
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
||||
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)
|
||||
{
|
||||
// Need to escape each path segment while keeping the directory separators intact
|
||||
return string.Join(
|
||||
Path.AltDirectorySeparatorChar,
|
||||
relativeFilePath
|
||||
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.Select(Uri.EscapeDataString)
|
||||
);
|
||||
// Create a 'file:///' URI and then strip the 'file:///' prefix to allow for relative paths
|
||||
return new Uri(new Uri("file:///"), optimalFilePath).ToString()[8..];
|
||||
}
|
||||
|
||||
return relativeFilePath;
|
||||
return optimalFilePath;
|
||||
}
|
||||
// Try to catch only exceptions related to failed HTTP requests
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,36 +10,87 @@ using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
public partial record 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)
|
||||
public partial class ExportRequest
|
||||
{
|
||||
private string? _outputBaseFilePath;
|
||||
public string OutputBaseFilePath => _outputBaseFilePath ??= GetOutputBaseFilePath(
|
||||
Guild,
|
||||
Channel,
|
||||
OutputPath,
|
||||
Format,
|
||||
After,
|
||||
Before
|
||||
);
|
||||
public Guild Guild { get; }
|
||||
|
||||
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(
|
||||
Guild guild,
|
||||
@@ -51,7 +102,7 @@ public partial record ExportRequest
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
// 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
|
||||
if (after is not null || before is not null)
|
||||
@@ -83,6 +134,35 @@ public partial record ExportRequest
|
||||
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(
|
||||
Guild guild,
|
||||
Channel channel,
|
||||
@@ -91,27 +171,7 @@ public partial record ExportRequest
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null)
|
||||
{
|
||||
// Format path
|
||||
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
|
||||
})
|
||||
);
|
||||
var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
|
||||
|
||||
// Output is a directory
|
||||
if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath)))
|
||||
|
||||
@@ -11,6 +11,7 @@ internal class FromMessageFilter : MessageFilter
|
||||
|
||||
public override bool IsMatch(Message message) =>
|
||||
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.Id.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ internal class MentionsMessageFilter : MessageFilter
|
||||
|
||||
public override bool IsMatch(Message message) => message.MentionedUsers.Any(user =>
|
||||
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.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
|
||||
@@ -25,59 +25,59 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
_isJumbo = isJumbo;
|
||||
}
|
||||
|
||||
protected override async ValueTask<MarkdownNode> VisitTextAsync(
|
||||
protected override ValueTask VisitTextAsync(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (openingTag, closingTag) = formatting.Kind switch
|
||||
{
|
||||
FormattingKind.Bold => (
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"<strong>",
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"</strong>"
|
||||
),
|
||||
|
||||
FormattingKind.Italic => (
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"<em>",
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"</em>"
|
||||
),
|
||||
|
||||
FormattingKind.Underline => (
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"<u>",
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"</u>"
|
||||
),
|
||||
|
||||
FormattingKind.Strikethrough => (
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"<s>",
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"</s>"
|
||||
),
|
||||
|
||||
FormattingKind.Spoiler => (
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"""</span>"""
|
||||
),
|
||||
|
||||
FormattingKind.Quote => (
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"""</div></div>"""
|
||||
),
|
||||
|
||||
@@ -85,27 +85,76 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
};
|
||||
|
||||
_buffer.Append(openingTag);
|
||||
var result = await base.VisitFormattingAsync(formatting, cancellationToken);
|
||||
await VisitAsync(formatting.Children, cancellationToken);
|
||||
_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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
$"""
|
||||
<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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -114,16 +163,16 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
: "nohighlight";
|
||||
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
$"""
|
||||
<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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -135,21 +184,21 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
|
||||
_buffer.Append(
|
||||
!string.IsNullOrWhiteSpace(linkedMessageId)
|
||||
// language=HTML
|
||||
// lang=html
|
||||
? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">"""
|
||||
// language=HTML
|
||||
// lang=html
|
||||
: $"""<a href="{HtmlEncode(link.Url)}">"""
|
||||
);
|
||||
|
||||
var result = await base.VisitLinkAsync(link, cancellationToken);
|
||||
await VisitAsync(link.Children, cancellationToken);
|
||||
|
||||
// language=HTML
|
||||
_buffer.Append("</a>");
|
||||
|
||||
return result;
|
||||
_buffer.Append(
|
||||
// lang=html
|
||||
"</a>"
|
||||
);
|
||||
}
|
||||
|
||||
protected override async ValueTask<MarkdownNode> VisitEmojiAsync(
|
||||
protected override async ValueTask VisitEmojiAsync(
|
||||
EmojiNode emoji,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -157,7 +206,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
|
||||
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
$"""
|
||||
<img
|
||||
loading="lazy"
|
||||
@@ -167,18 +216,15 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
src="{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
|
||||
"""
|
||||
);
|
||||
|
||||
return await base.VisitEmojiAsync(emoji, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async ValueTask<MarkdownNode> VisitMentionAsync(
|
||||
MentionNode mention,
|
||||
protected override async ValueTask VisitMentionAsync(MentionNode mention,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
{
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"""
|
||||
<span class="chatlog__markdown-mention">@everyone</span>
|
||||
"""
|
||||
@@ -187,7 +233,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
else if (mention.Kind == MentionKind.Here)
|
||||
{
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
"""
|
||||
<span class="chatlog__markdown-mention">@here</span>
|
||||
"""
|
||||
@@ -203,23 +249,23 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
|
||||
var member = mention.TargetId?.Pipe(_context.TryGetMember);
|
||||
var fullName = member?.User.FullName ?? "Unknown";
|
||||
var nick = member?.Nick ?? "Unknown";
|
||||
var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
|
||||
|
||||
_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)
|
||||
{
|
||||
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
||||
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
||||
var symbol = channel?.Kind.IsVoice() == true ? "🔊" : "#";
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
$"""
|
||||
<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);
|
||||
"""
|
||||
: "";
|
||||
: null;
|
||||
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
$"""
|
||||
<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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -261,13 +305,13 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
var formattedLong = timestamp.Instant?.ToLocalString("dddd, MMMM d, yyyy h:mm tt") ?? "";
|
||||
|
||||
_buffer.Append(
|
||||
// language=HTML
|
||||
// lang=html
|
||||
$"""
|
||||
<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)
|
||||
return false;
|
||||
|
||||
// Messages must be from the same author
|
||||
// Messages must be sent by the same author
|
||||
if (message.Author.Id != lastMessage.Author.Id)
|
||||
return false;
|
||||
|
||||
// If the user changed their name after the last message, their new messages
|
||||
// cannot join an existing group.
|
||||
// If the author changed their name after the last message, their new messages
|
||||
// cannot join the existing group.
|
||||
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -44,19 +45,47 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteString("id", user.Id.ToString());
|
||||
_writer.WriteString("name", user.Name);
|
||||
_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.WriteBoolean("isBot", user.IsBot);
|
||||
|
||||
_writer.WritePropertyName("roles");
|
||||
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
||||
|
||||
_writer.WriteString(
|
||||
"avatarUrl",
|
||||
await Context.ResolveAssetUrlAsync(user.AvatarUrl, cancellationToken)
|
||||
await Context.ResolveAssetUrlAsync(
|
||||
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
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(
|
||||
EmbedAuthor embedAuthor,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -212,8 +241,8 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteStartObject("channel");
|
||||
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
||||
_writer.WriteString("type", Context.Request.Channel.Kind.ToString());
|
||||
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString());
|
||||
_writer.WriteString("category", Context.Request.Channel.Category.Name);
|
||||
_writer.WriteString("categoryId", Context.Request.Channel.Parent?.Id.ToString());
|
||||
_writer.WriteString("category", Context.Request.Channel.Category);
|
||||
_writer.WriteString("name", Context.Request.Channel.Name);
|
||||
_writer.WriteString("topic", Context.Request.Channel.Topic);
|
||||
|
||||
@@ -233,6 +262,9 @@ internal class JsonMessageWriter : MessageWriter
|
||||
_writer.WriteString("before", Context.Request.Before?.ToDate());
|
||||
_writer.WriteEndObject();
|
||||
|
||||
// Timestamp
|
||||
_writer.WriteString("exportedAt", System.DateTimeOffset.UtcNow);
|
||||
|
||||
// Message array (start)
|
||||
_writer.WriteStartArray("messages");
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
|
||||
@@ -39,7 +39,7 @@ internal partial class MessageExporter : IAsyncDisposable
|
||||
|
||||
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 &&
|
||||
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten))
|
||||
{
|
||||
@@ -51,8 +51,8 @@ internal partial class MessageExporter : IAsyncDisposable
|
||||
if (_writer is not null)
|
||||
return _writer;
|
||||
|
||||
Directory.CreateDirectory(_context.Request.OutputBaseDirPath);
|
||||
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex);
|
||||
Directory.CreateDirectory(_context.Request.OutputDirPath);
|
||||
var filePath = GetPartitionFilePath(_context.Request.OutputFilePath, _partitionIndex);
|
||||
|
||||
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context);
|
||||
await writer.WritePreambleAsync(cancellationToken);
|
||||
@@ -74,11 +74,11 @@ internal partial class MessageExporter
|
||||
{
|
||||
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)
|
||||
return baseFilePath;
|
||||
|
||||
// Inject partition index into file name
|
||||
// Inject partition index into the file name
|
||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||
var fileExt = Path.GetExtension(baseFilePath);
|
||||
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
||||
|
||||
@@ -40,13 +40,13 @@
|
||||
|
||||
var authorMember = Context.TryGetMember(message.Author.Id);
|
||||
var authorColor = Context.TryGetUserColor(message.Author.Id);
|
||||
var authorNick = message.Author.IsBot
|
||||
? message.Author.Name
|
||||
: authorMember?.Nick ?? message.Author.Name;
|
||||
var authorDisplayName = message.Author.IsBot
|
||||
? message.Author.DisplayName
|
||||
: 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 class="chatlog__message">
|
||||
@{/* System notification */}
|
||||
@* System notification *@
|
||||
@if (message.Kind.IsSystemNotification())
|
||||
{
|
||||
<div class="chatlog__message-aside">
|
||||
@@ -70,18 +70,18 @@
|
||||
</div>
|
||||
|
||||
<div class="chatlog__message-primary">
|
||||
@{/* 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>
|
||||
@* 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">@authorDisplayName</span>
|
||||
|
||||
@{/* Space out the content */}
|
||||
@* Space out the content *@
|
||||
<span> </span>
|
||||
|
||||
@{/* System notification content */}
|
||||
@* System notification content *@
|
||||
<span class="chatlog__system-notification-content">
|
||||
@if (message.Kind == MessageKind.RecipientAdd && message.MentionedUsers.Any())
|
||||
{
|
||||
<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>
|
||||
}
|
||||
else if (message.Kind == MessageKind.RecipientRemove && message.MentionedUsers.Any())
|
||||
@@ -93,7 +93,7 @@
|
||||
else
|
||||
{
|
||||
<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>
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
</span>
|
||||
|
||||
@{/* Timestamp */}
|
||||
@* Timestamp *@
|
||||
<span class="chatlog__system-notification-timestamp">
|
||||
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
|
||||
</span>
|
||||
@@ -149,7 +149,7 @@
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -168,12 +168,12 @@
|
||||
{
|
||||
var referencedUserMember = Context.TryGetMember(message.ReferencedMessage.Author.Id);
|
||||
var referencedUserColor = Context.TryGetUserColor(message.ReferencedMessage.Author.Id);
|
||||
var referencedUserNick = message.ReferencedMessage.Author.IsBot
|
||||
? message.ReferencedMessage.Author.Name
|
||||
: referencedUserMember?.Nick ?? message.ReferencedMessage.Author.Name;
|
||||
var referencedUserDisplayName = message.ReferencedMessage.Author.IsBot
|
||||
? message.ReferencedMessage.Author.DisplayName
|
||||
: referencedUserMember?.DisplayName ?? message.ReferencedMessage.Author.DisplayName;
|
||||
|
||||
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(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>
|
||||
<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">@referencedUserDisplayName</div>
|
||||
<div class="chatlog__reply-content">
|
||||
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
|
||||
@@ -201,12 +201,12 @@
|
||||
{
|
||||
var interactionUserMember = Context.TryGetMember(message.Interaction.User.Id);
|
||||
var interactionUserColor = Context.TryGetUserColor(message.Interaction.User.Id);
|
||||
var interactionUserNick = message.Interaction.User.IsBot
|
||||
? message.Interaction.User.Name
|
||||
: interactionUserMember?.Nick ?? message.Interaction.User.Name;
|
||||
var interactionUserDisplayName = message.Interaction.User.IsBot
|
||||
? message.Interaction.User.DisplayName
|
||||
: interactionUserMember?.DisplayName ?? message.Interaction.User.DisplayName;
|
||||
|
||||
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(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>
|
||||
<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">@interactionUserDisplayName</div>
|
||||
<div class="chatlog__reply-content">
|
||||
used /@message.Interaction.Name
|
||||
</div>
|
||||
@@ -222,10 +222,10 @@
|
||||
|
||||
// Header
|
||||
<div class="chatlog__header">
|
||||
@{/* 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>
|
||||
@* 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">@authorDisplayName</span>
|
||||
|
||||
@{/* Bot tag */}
|
||||
@* Bot tag *@
|
||||
@if (message.Author.IsBot)
|
||||
{
|
||||
// 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>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Content */}
|
||||
@* Content *@
|
||||
@if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
|
||||
{
|
||||
<div class="chatlog__content chatlog__markdown">
|
||||
@{/* Text */}
|
||||
@* Text *@
|
||||
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||
@@ -262,17 +262,17 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Attachments */}
|
||||
@* Attachments *@
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
|
||||
@{/* Spoiler caption */}
|
||||
@* Spoiler caption *@
|
||||
@if (attachment.IsSpoiler)
|
||||
{
|
||||
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
|
||||
}
|
||||
|
||||
@{/* Attachment preview */}
|
||||
@* Attachment preview *@
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||
@@ -310,7 +310,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Invites */}
|
||||
@* Invites *@
|
||||
@{
|
||||
var inviteCodes = MarkdownParser
|
||||
.ExtractLinks(message.Content)
|
||||
@@ -321,7 +321,7 @@
|
||||
|
||||
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)
|
||||
{
|
||||
continue;
|
||||
@@ -329,22 +329,22 @@
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<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-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 class="chatlog__embed-invite-info">
|
||||
<div class="chatlog__embed-invite-guild-name">
|
||||
<a href="https://discord.gg/@invite.Code">
|
||||
@(invite.Guild?.Name ?? "Unknown guild")
|
||||
@(invite.Guild.Name)
|
||||
</a>
|
||||
</div>
|
||||
<div class="chatlog__embed-invite-channel-name">
|
||||
<svg class="chatlog__embed-invite-channel-icon">
|
||||
<use href="#channel-icon"></use>
|
||||
</svg>
|
||||
<span> @(invite.Channel?.Name ?? "Unknown channel")</span>
|
||||
<span> @(invite.Channel?.Name ?? "Unknown Channel")</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,7 +353,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@{/* Embeds */}
|
||||
@* Embeds *@
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
// Spotify embed
|
||||
@@ -369,7 +369,7 @@
|
||||
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@* Color pill *@
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||
@@ -382,7 +382,7 @@
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@{/* Embed author */}
|
||||
@* Embed author *@
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author-container">
|
||||
@@ -407,7 +407,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@* Embed title *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@@ -424,7 +424,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Video player */}
|
||||
@* Video player *@
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||
</div>
|
||||
@@ -447,6 +447,19 @@
|
||||
</a>
|
||||
</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
|
||||
else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
@@ -455,8 +468,8 @@
|
||||
embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-gifv" loop width="@embed.Video?.Width" height="@embed.Video?.Height" onmouseover="this.play()" onmouseout="this.pause()">
|
||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
||||
<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 gifv">
|
||||
</video>
|
||||
</div>
|
||||
}
|
||||
@@ -464,7 +477,7 @@
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@{/* Color pill */}
|
||||
@* Color pill *@
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||
@@ -477,7 +490,7 @@
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@{/* Embed author */}
|
||||
@* Embed author *@
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author-container">
|
||||
@@ -502,7 +515,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed title */}
|
||||
@* Embed title *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@@ -519,7 +532,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed description */}
|
||||
@* Embed description *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
@@ -527,7 +540,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed fields */}
|
||||
@* Embed fields *@
|
||||
@if (embed.Fields.Any())
|
||||
{
|
||||
<div class="chatlog__embed-fields">
|
||||
@@ -553,7 +566,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@{/* Embed content */}
|
||||
@* Embed content *@
|
||||
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
@@ -564,7 +577,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@{/* Embed images */}
|
||||
@* Embed images *@
|
||||
@if (embed.Images.Any())
|
||||
{
|
||||
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
|
||||
@@ -582,18 +595,18 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Embed footer & icon */}
|
||||
@* Embed footer & icon *@
|
||||
@if (embed.Footer is not null || embed.Timestamp is not null)
|
||||
{
|
||||
<div class="chatlog__embed-footer">
|
||||
@{/* Footer icon */}
|
||||
@* Footer icon *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@{/* Footer text */}
|
||||
@* Footer text *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
@@ -604,7 +617,7 @@
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@{/* Embed timestamp */}
|
||||
@* Embed timestamp *@
|
||||
@if (embed.Timestamp is not null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
@@ -617,11 +630,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
@{/* Stickers */}
|
||||
@* Stickers *@
|
||||
@foreach (var sticker in message.Stickers)
|
||||
{
|
||||
<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">
|
||||
}
|
||||
@@ -632,7 +645,7 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@{/* Message reactions */}
|
||||
@* Message reactions *@
|
||||
@if (message.Reactions.Any())
|
||||
{
|
||||
<div class="chatlog__reactions">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -18,15 +19,15 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
_buffer = buffer;
|
||||
}
|
||||
|
||||
protected override async ValueTask<MarkdownNode> VisitTextAsync(
|
||||
protected override ValueTask VisitTextAsync(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_buffer.Append(text.Text);
|
||||
return await base.VisitTextAsync(text, cancellationToken);
|
||||
return default;
|
||||
}
|
||||
|
||||
protected override async ValueTask<MarkdownNode> VisitEmojiAsync(
|
||||
protected override ValueTask VisitEmojiAsync(
|
||||
EmojiNode emoji,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -36,11 +37,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
: emoji.Name
|
||||
);
|
||||
|
||||
return await base.VisitEmojiAsync(emoji, cancellationToken);
|
||||
return default;
|
||||
}
|
||||
|
||||
protected override async ValueTask<MarkdownNode> VisitMentionAsync(
|
||||
MentionNode mention,
|
||||
protected override async ValueTask VisitMentionAsync(MentionNode mention,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
@@ -60,9 +60,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken);
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -72,7 +72,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
_buffer.Append($"#{name}");
|
||||
|
||||
// Voice channel marker
|
||||
if (channel?.IsVoice == true)
|
||||
if (channel?.Kind.IsVoice() == true)
|
||||
_buffer.Append(" [voice]");
|
||||
}
|
||||
else if (mention.Kind == MentionKind.Role)
|
||||
@@ -82,11 +82,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
|
||||
_buffer.Append($"@{name}");
|
||||
}
|
||||
|
||||
return await base.VisitMentionAsync(mention, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async ValueTask<MarkdownNode> VisitTimestampAsync(
|
||||
protected override ValueTask VisitTimestampAsync(
|
||||
TimestampNode timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -98,7 +96,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
: "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
|
||||
{
|
||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().Name} to the group."
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
|
||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||
? message.Author.Id == message.MentionedUsers.First().Id
|
||||
? "Left the group."
|
||||
: $"Removed {message.MentionedUsers.First().Name} from the group."
|
||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call =>
|
||||
|
||||
@@ -193,7 +193,7 @@ internal class PlainTextMessageWriter : MessageWriter
|
||||
{
|
||||
await _writer.WriteLineAsync(new string('=', 62));
|
||||
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))
|
||||
{
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
? darkVariant
|
||||
: lightVariant;
|
||||
|
||||
string GetFontUrl(int weight) =>
|
||||
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff";
|
||||
string GetFontUrl(string style, int weight) =>
|
||||
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/ggsans-{style}-{weight}.woff2";
|
||||
|
||||
ValueTask<string> ResolveAssetUrlAsync(string url) =>
|
||||
Context.ResolveAssetUrlAsync(url, CancellationToken);
|
||||
@@ -38,36 +38,76 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
|
||||
@{/* Styling */}
|
||||
@* Styling *@
|
||||
<style>
|
||||
@@font-face {
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(300)));
|
||||
font-family: Whitney;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(400)));
|
||||
font-family: Whitney;
|
||||
src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 400))");
|
||||
font-family: gg sans;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(500)));
|
||||
font-family: Whitney;
|
||||
src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 500))");
|
||||
font-family: gg sans;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(600)));
|
||||
font-family: Whitney;
|
||||
src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 600))");
|
||||
font-family: gg sans;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@font-face {
|
||||
src: url(@await ResolveAssetUrlAsync(GetFontUrl(700)));
|
||||
font-family: Whitney;
|
||||
src: url("@await ResolveAssetUrlAsync(GetFontUrl("normal", 700))");
|
||||
font-family: gg sans;
|
||||
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 {
|
||||
@@ -75,7 +115,7 @@
|
||||
padding: 0;
|
||||
background-color: @Themed("#36393e", "#ffffff");
|
||||
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-weight: @Themed("400", "500");
|
||||
scroll-behavior: smooth;
|
||||
@@ -582,24 +622,6 @@
|
||||
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 {
|
||||
min-width: 320px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
@@ -650,6 +672,33 @@
|
||||
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 {
|
||||
border: 0;
|
||||
}
|
||||
@@ -709,6 +758,36 @@
|
||||
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 {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -816,7 +895,7 @@
|
||||
}
|
||||
</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")">
|
||||
<script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
|
||||
<script>
|
||||
@@ -825,7 +904,7 @@
|
||||
});
|
||||
</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>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -846,7 +925,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
@{/* Scripts */}
|
||||
@* Scripts *@
|
||||
<script>
|
||||
function scrollToMessage(event, id) {
|
||||
const element = document.getElementById('chatlog__message-container-' + id);
|
||||
@@ -883,7 +962,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
@{/* Icons */}
|
||||
@* Icons *@
|
||||
<svg style="display: none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<symbol id="attachment-icon" viewBox="0 0 720 960">
|
||||
@@ -925,7 +1004,7 @@
|
||||
</div>
|
||||
<div class="preamble__entries-container">
|
||||
<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))
|
||||
{
|
||||
@@ -952,7 +1031,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@{/* Preamble cuts off at this point */}
|
||||
@* Preamble cuts off at this point *@
|
||||
<!--wmm:ignore-->
|
||||
<div class="chatlog">
|
||||
<!--/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 */
|
||||
|
||||
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),
|
||||
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
|
||||
// Opening asterisk must not be followed by whitespace
|
||||
// Closing asterisk must not be preceded by whitespace
|
||||
// There must be exactly one closing asterisk.
|
||||
// Opening asterisk must not be followed by whitespace.
|
||||
// Closing asterisk must not be preceded by whitespace.
|
||||
new Regex(@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
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),
|
||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher))
|
||||
);
|
||||
|
||||
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
|
||||
new Regex(@"_([^_]+)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
// Closing underscore must not be followed by a word character.
|
||||
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
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),
|
||||
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
|
||||
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),
|
||||
(s, m) => new FormattingNode(
|
||||
FormattingKind.Italic,
|
||||
@@ -66,62 +65,63 @@ internal static partial class MarkdownParser
|
||||
)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture any character until the earliest double tilde
|
||||
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
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>(
|
||||
// Capture any character until the earliest double pipe
|
||||
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Capture any character until the end of the line
|
||||
// Opening 'greater than' character must be followed by whitespace
|
||||
// Text content is optional
|
||||
new Regex(@"^>\s(.*\n?)", DefaultRegexOptions),
|
||||
// Include the linebreak in the content so that the lines are preserved in quotes.
|
||||
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
|
||||
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Repeatedly capture any character until the end of the line
|
||||
// This one is tricky as it ends up producing multiple separate captures which need to be joined
|
||||
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
|
||||
(_, m) => new FormattingNode(
|
||||
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> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Include the linebreaks in the content, so that the lines are preserved in quotes.
|
||||
new Regex(@"(?:^>\s(.+\n?)){2,}", DefaultRegexOptions),
|
||||
(s, m) => new FormattingNode(
|
||||
FormattingKind.Quote,
|
||||
m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
|
||||
)
|
||||
);
|
||||
|
||||
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),
|
||||
(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 */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Capture any character except backtick until a backtick
|
||||
// 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
|
||||
// One or two backticks are allowed, but they must match on both sides.
|
||||
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>(
|
||||
// Capture language identifier and then any character until the earliest triple backtick
|
||||
// Language identifier is one word immediately after opening backticks, followed immediately by newline
|
||||
// Blank lines at the beginning and end of content are trimmed
|
||||
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
|
||||
// Blank lines at the beginning and at the end of content are trimmed.
|
||||
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(_, 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>(
|
||||
// Capture :thinking: for known emoji codes
|
||||
// Capture :thinking:
|
||||
new Regex(@":([\w_]+):", DefaultRegexOptions),
|
||||
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
|
||||
);
|
||||
@@ -224,15 +224,9 @@ internal static partial class MarkdownParser
|
||||
|
||||
/* 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>(
|
||||
// Capture any non-whitespace character after http:// or https://
|
||||
// until the last punctuation character or whitespace
|
||||
// Any non-whitespace character after http:// or https://
|
||||
// until the last punctuation character or whitespace.
|
||||
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
|
||||
(_, m) => new LinkNode(m.Groups[1].Value)
|
||||
);
|
||||
@@ -243,32 +237,38 @@ internal static partial class MarkdownParser
|
||||
(_, 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 */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||
// Capture the shrug kaomoji
|
||||
// This escapes it from matching for formatting
|
||||
// Capture the shrug kaomoji.
|
||||
// This escapes it from matching for formatting.
|
||||
@"¯\_(ツ)_/¯",
|
||||
s => new TextNode(s.ToString())
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Capture some specific emoji that don't get rendered
|
||||
// This escapes it from matching for emoji
|
||||
// Capture some specific emoji that don't get rendered.
|
||||
// This escapes them from matching for emoji.
|
||||
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
|
||||
// This escapes it from matching for emoji
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash.
|
||||
// This escapes them from matching for emoji.
|
||||
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
|
||||
// This escapes it from matching for formatting or other tokens
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
||||
// This escapes them from matching for formatting or other tokens.
|
||||
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
|
||||
(_, 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
|
||||
private static readonly IMatcher<MarkdownNode> AggregateNodeMatcher = new AggregateMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>(
|
||||
// Escaped text
|
||||
ShrugTextNodeMatcher,
|
||||
IgnoredEmojiTextNodeMatcher,
|
||||
@@ -331,6 +330,8 @@ internal static partial class MarkdownParser
|
||||
MultiLineQuoteNodeMatcher,
|
||||
RepeatedSingleLineQuoteNodeMatcher,
|
||||
SingleLineQuoteNodeMatcher,
|
||||
HeadingNodeMatcher,
|
||||
ListNodeMatcher,
|
||||
|
||||
// Code blocks
|
||||
MultiLineCodeBlockNodeMatcher,
|
||||
@@ -344,7 +345,7 @@ internal static partial class MarkdownParser
|
||||
RoleMentionNodeMatcher,
|
||||
|
||||
// Links
|
||||
TitledLinkNodeMatcher,
|
||||
MaskedLinkNodeMatcher,
|
||||
AutoLinkNodeMatcher,
|
||||
HiddenLinkNodeMatcher,
|
||||
|
||||
@@ -358,7 +359,7 @@ internal static partial class MarkdownParser
|
||||
);
|
||||
|
||||
// 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
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
@@ -383,13 +384,13 @@ internal static partial class MarkdownParser
|
||||
internal static partial class MarkdownParser
|
||||
{
|
||||
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
|
||||
Parse(segment, AggregateNodeMatcher);
|
||||
Parse(segment, NodeMatcher);
|
||||
|
||||
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
|
||||
Parse(new StringSegment(markdown));
|
||||
|
||||
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
|
||||
Parse(segment, MinimalAggregateNodeMatcher);
|
||||
Parse(segment, MinimalNodeMatcher);
|
||||
|
||||
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
|
||||
ParseMinimal(new StringSegment(markdown));
|
||||
|
||||
@@ -7,82 +7,127 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
internal abstract class MarkdownVisitor
|
||||
{
|
||||
protected virtual ValueTask<MarkdownNode> VisitTextAsync(
|
||||
protected virtual ValueTask VisitTextAsync(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(text);
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
|
||||
protected virtual async ValueTask<MarkdownNode> VisitFormattingAsync(
|
||||
protected virtual async ValueTask VisitFormattingAsync(
|
||||
FormattingNode formatting,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
CancellationToken cancellationToken = default) =>
|
||||
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,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(inlineCodeBlock);
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
|
||||
protected virtual ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(
|
||||
protected virtual ValueTask VisitMultiLineCodeBlockAsync(
|
||||
MultiLineCodeBlockNode multiLineCodeBlock,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
new(multiLineCodeBlock);
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
|
||||
protected virtual async ValueTask<MarkdownNode> VisitLinkAsync(
|
||||
protected virtual async ValueTask VisitLinkAsync(
|
||||
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)
|
||||
{
|
||||
await VisitAsync(link.Children, cancellationToken);
|
||||
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
|
||||
if (node is TextNode text)
|
||||
{
|
||||
TextNode text =>
|
||||
await VisitTextAsync(text, cancellationToken),
|
||||
await VisitTextAsync(text, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
FormattingNode formatting =>
|
||||
await VisitFormattingAsync(formatting, cancellationToken),
|
||||
if (node is FormattingNode formatting)
|
||||
{
|
||||
await VisitFormattingAsync(formatting, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
InlineCodeBlockNode inlineCodeBlock =>
|
||||
await VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken),
|
||||
if (node is HeadingNode heading)
|
||||
{
|
||||
await VisitHeadingAsync(heading, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
MultiLineCodeBlockNode multiLineCodeBlock =>
|
||||
await VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken),
|
||||
if (node is ListNode list)
|
||||
{
|
||||
await VisitListAsync(list, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
LinkNode link =>
|
||||
await VisitLinkAsync(link, cancellationToken),
|
||||
if (node is ListItemNode listItem)
|
||||
{
|
||||
await VisitListItemAsync(listItem, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
EmojiNode emoji =>
|
||||
await VisitEmojiAsync(emoji, cancellationToken),
|
||||
if (node is InlineCodeBlockNode inlineCodeBlock)
|
||||
{
|
||||
await VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
MentionNode mention =>
|
||||
await VisitMentionAsync(mention, cancellationToken),
|
||||
if (node is MultiLineCodeBlockNode multiLineCodeBlock)
|
||||
{
|
||||
await VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
TimestampNode timestamp =>
|
||||
await VisitTimestampAsync(timestamp, cancellationToken),
|
||||
if (node is LinkNode link)
|
||||
{
|
||||
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(
|
||||
IEnumerable<MarkdownNode> nodes,
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ using System.Threading.Tasks;
|
||||
|
||||
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)
|
||||
{
|
||||
var list = new List<T>();
|
||||
@@ -19,5 +19,5 @@ public static class AsyncExtensions
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
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);
|
||||
|
||||
foreach (var t in data)
|
||||
buffer.Append(t.ToString("X2"));
|
||||
foreach (var b in data)
|
||||
buffer.Append(b.ToString("X2"));
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
@@ -10,4 +11,7 @@ public static class GenericExtensions
|
||||
!predicate(value)
|
||||
? value
|
||||
: 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) =>
|
||||
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
|
||||
(int)statusCode >= 500;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
||||
set => SetValue(SelectedItemsProperty, value);
|
||||
}
|
||||
|
||||
// Propagate selected items from model to view
|
||||
// Propagate selected items from the model to the view
|
||||
private void SelectItems()
|
||||
{
|
||||
_viewHandled = true;
|
||||
@@ -57,7 +57,7 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
||||
_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)
|
||||
{
|
||||
if (_viewHandled) return;
|
||||
@@ -66,7 +66,6 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
||||
SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
|
||||
}
|
||||
|
||||
// Re-select items when the set of items changes
|
||||
private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args)
|
||||
{
|
||||
if (_viewHandled) return;
|
||||
@@ -82,7 +81,6 @@ public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
||||
((INotifyCollectionChanged) AssociatedObject.Items).CollectionChanged += OnListBoxItemsChanged;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
base.OnDetaching();
|
||||
|
||||
@@ -17,8 +17,8 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
|
||||
{
|
||||
base.OnStart();
|
||||
|
||||
// Set default theme
|
||||
// (preferred theme will be set later, once the settings are loaded)
|
||||
// Set the default theme.
|
||||
// Preferred theme will be set later, once the settings are loaded.
|
||||
App.SetLightTheme();
|
||||
}
|
||||
|
||||
@@ -26,20 +26,17 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
|
||||
{
|
||||
base.ConfigureIoC(builder);
|
||||
|
||||
// Bind settings as singleton
|
||||
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
|
||||
|
||||
// Bind view model factory
|
||||
builder.Bind<IViewModelFactory>().ToAbstractFactory();
|
||||
}
|
||||
|
||||
#if !DEBUG
|
||||
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
|
||||
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
|
||||
{
|
||||
base.OnUnhandledException(e);
|
||||
base.OnUnhandledException(args);
|
||||
|
||||
MessageBox.Show(
|
||||
e.Exception.ToString(),
|
||||
args.Exception.ToString(),
|
||||
"Error occured",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error
|
||||
|
||||
@@ -13,17 +13,17 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FuckRussia" Version="1.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.0.1" />
|
||||
<PackageReference Include="MaterialDesignColors" Version="2.1.1" />
|
||||
<PackageReference Include="MaterialDesignThemes" Version="4.7.1" />
|
||||
<PackageReference Include="Cogwheel" Version="2.0.3" />
|
||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
|
||||
<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="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
||||
<PackageReference Include="Onova" Version="2.6.4" />
|
||||
<PackageReference Include="Stylet" Version="1.3.6" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||
<PackageReference Include="Onova" Version="2.6.10" />
|
||||
<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>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Cogwheel;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using Microsoft.Win32;
|
||||
using Tyrrrz.Settings;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Services;
|
||||
|
||||
public partial class SettingsService : SettingsManager
|
||||
public partial class SettingsService : SettingsBase
|
||||
{
|
||||
public bool IsUkraineSupportMessageEnabled { get; set; } = true;
|
||||
|
||||
@@ -35,14 +36,24 @@ public partial class SettingsService : SettingsManager
|
||||
|
||||
public bool LastShouldReuseAssets { get; set; }
|
||||
|
||||
public string? LastAssetsDirPath { get; set; }
|
||||
|
||||
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
|
||||
|
||||
@@ -6,13 +6,15 @@ internal static class ProcessEx
|
||||
{
|
||||
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; }
|
||||
|
||||
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 bool IsDirectMessageGuildSelected => SelectedGuild?.Id == Guild.DirectMessages.Id;
|
||||
|
||||
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
|
||||
? GuildChannelMap?[SelectedGuild]
|
||||
? ChannelsByGuild?[SelectedGuild]
|
||||
: null;
|
||||
|
||||
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
|
||||
@@ -65,16 +65,21 @@ public class DashboardViewModel : PropertyChangedBase
|
||||
|
||||
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
||||
|
||||
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||
this.Bind(
|
||||
o => o.IsBusy,
|
||||
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
|
||||
);
|
||||
|
||||
Progress.Bind(
|
||||
o => o.Current,
|
||||
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
|
||||
);
|
||||
}
|
||||
|
||||
public void OnViewLoaded()
|
||||
{
|
||||
if (_settingsService.LastToken is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_settingsService.LastToken))
|
||||
Token = _settingsService.LastToken;
|
||||
}
|
||||
}
|
||||
|
||||
public async void ShowSettings()
|
||||
@@ -103,17 +108,17 @@ public class DashboardViewModel : PropertyChangedBase
|
||||
|
||||
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())
|
||||
{
|
||||
guildChannelMap[guild] = (await discord.GetGuildChannelsAsync(guild.Id))
|
||||
channelsByGuild[guild] = (await discord.GetGuildChannelsAsync(guild.Id))
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
_discord = discord;
|
||||
GuildChannelMap = guildChannelMap;
|
||||
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
|
||||
ChannelsByGuild = channelsByGuild;
|
||||
SelectedGuild = channelsByGuild.Keys.FirstOrDefault();
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||
{
|
||||
@@ -158,14 +163,11 @@ public class DashboardViewModel : PropertyChangedBase
|
||||
|
||||
var exporter = new ChannelExporter(_discord);
|
||||
|
||||
var channelProgressPairs = dialog
|
||||
.Channels!
|
||||
.Select(c => new
|
||||
{
|
||||
Channel = c,
|
||||
Progress = _progressMuxer.CreateInput()
|
||||
})
|
||||
.ToArray();
|
||||
var channelProgressPairs = dialog.Channels!.Select(c => new
|
||||
{
|
||||
Channel = c,
|
||||
Progress = _progressMuxer.CreateInput()
|
||||
}).ToArray();
|
||||
|
||||
var successfulExportCount = 0;
|
||||
|
||||
@@ -186,6 +188,7 @@ public class DashboardViewModel : PropertyChangedBase
|
||||
dialog.Guild!,
|
||||
channel,
|
||||
dialog.OutputPath!,
|
||||
dialog.AssetsDirPath,
|
||||
dialog.SelectedFormat,
|
||||
dialog.After?.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)
|
||||
{
|
||||
_eventAggregator.Publish(
|
||||
|
||||
@@ -65,6 +65,8 @@ public class ExportSetupViewModel : DialogScreen
|
||||
|
||||
public bool ShouldReuseAssets { get; set; }
|
||||
|
||||
public string? AssetsDirPath { get; set; }
|
||||
|
||||
public bool IsAdvancedSectionDisplayed { get; set; }
|
||||
|
||||
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
|
||||
@@ -79,15 +81,18 @@ public class ExportSetupViewModel : DialogScreen
|
||||
ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown;
|
||||
ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets;
|
||||
ShouldReuseAssets = _settingsService.LastShouldReuseAssets;
|
||||
AssetsDirPath = _settingsService.LastAssetsDirPath;
|
||||
|
||||
// Show the "advanced options" section by default if any
|
||||
// of the advanced options are set to non-default values.
|
||||
IsAdvancedSectionDisplayed =
|
||||
After != default ||
|
||||
Before != default ||
|
||||
After is not null ||
|
||||
Before is not null ||
|
||||
!string.IsNullOrWhiteSpace(PartitionLimitValue) ||
|
||||
!string.IsNullOrWhiteSpace(MessageFilterValue) ||
|
||||
ShouldDownloadAssets != default;
|
||||
ShouldDownloadAssets ||
|
||||
ShouldReuseAssets ||
|
||||
!string.IsNullOrWhiteSpace(AssetsDirPath);
|
||||
}
|
||||
|
||||
public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed;
|
||||
@@ -107,18 +112,25 @@ public class ExportSetupViewModel : DialogScreen
|
||||
var extension = SelectedFormat.GetFileExtension();
|
||||
var filter = $"{extension.ToUpperInvariant()} files|*.{extension}";
|
||||
|
||||
var outputPath = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
OutputPath = outputPath;
|
||||
var path = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
OutputPath = path;
|
||||
}
|
||||
else
|
||||
{
|
||||
var outputPath = _dialogManager.PromptDirectoryPath();
|
||||
if (!string.IsNullOrWhiteSpace(outputPath))
|
||||
OutputPath = outputPath;
|
||||
var path = _dialogManager.PromptDirectoryPath();
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
OutputPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
public void ShowAssetsDirPathPrompt()
|
||||
{
|
||||
var path = _dialogManager.PromptDirectoryPath();
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
AssetsDirPath = path;
|
||||
}
|
||||
|
||||
public void Confirm()
|
||||
{
|
||||
// Prompt the output path if it's not set yet
|
||||
@@ -138,6 +150,7 @@ public class ExportSetupViewModel : DialogScreen
|
||||
_settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
|
||||
_settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
|
||||
_settingsService.LastShouldReuseAssets = ShouldReuseAssets;
|
||||
_settingsService.LastAssetsDirPath = AssetsDirPath;
|
||||
|
||||
Close(true);
|
||||
}
|
||||
@@ -145,8 +158,10 @@ public class ExportSetupViewModel : DialogScreen
|
||||
|
||||
public static class ExportSetupViewModelExtensions
|
||||
{
|
||||
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory,
|
||||
Guild guild, IReadOnlyList<Channel> channels)
|
||||
public static ExportSetupViewModel CreateExportSetupViewModel(
|
||||
this IViewModelFactory factory,
|
||||
Guild guild,
|
||||
IReadOnlyList<Channel> channels)
|
||||
{
|
||||
var viewModel = factory.CreateExportSetupViewModel();
|
||||
|
||||
|
||||
@@ -25,13 +25,15 @@ public static class MessageBoxViewModelExtensions
|
||||
{
|
||||
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
||||
this IViewModelFactory factory,
|
||||
string title, string message,
|
||||
string? okButtonText, string? cancelButtonText)
|
||||
string title,
|
||||
string message,
|
||||
string? okButtonText,
|
||||
string? cancelButtonText)
|
||||
{
|
||||
var viewModel = factory.CreateMessageBoxViewModel();
|
||||
|
||||
viewModel.Title = title;
|
||||
viewModel.Message = message;
|
||||
|
||||
viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText);
|
||||
viewModel.OkButtonText = okButtonText;
|
||||
viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText);
|
||||
@@ -42,6 +44,7 @@ public static class MessageBoxViewModelExtensions
|
||||
|
||||
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
||||
this IViewModelFactory factory,
|
||||
string title, string message) =>
|
||||
string title,
|
||||
string message) =>
|
||||
factory.CreateMessageBoxViewModel(title, message, "CLOSE", null);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public class DialogManager : IDisposable
|
||||
|
||||
void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
|
||||
{
|
||||
void OnScreenClosed(object? closeSender, EventArgs args)
|
||||
void OnScreenClosed(object? closeSender, EventArgs closeArgs)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user