mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 07:04:40 +02:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5318e77b7 | |||
| e171b43c22 | |||
| 23e1850d15 | |||
| 057902f919 | |||
| 565fb53d3b | |||
| edba9f36a7 | |||
| 356b1a6afd | |||
| 70edc8989c | |||
| 2fd7e327a9 | |||
| 9182c3d934 | |||
| 01389b7e28 | |||
| bd0701cb82 | |||
| 4283ef22b1 | |||
| 94ef4b6981 | |||
| 7b72f473fd | |||
| 2463cb5087 | |||
| 2c7986c4e6 | |||
| 5755334492 | |||
| 26c523a3e9 | |||
| 371c7d2b75 | |||
| 62400f305a | |||
| 65a528e7fe | |||
| 47d8163a46 | |||
| 1d9224ebd8 | |||
| ddc7f921bd | |||
| aca94ddccb | |||
| 3661c42df4 | |||
| 005c1786ec | |||
| b6fbf2393e | |||
| 0e49936e38 | |||
| 8d4e6b77c4 | |||
| 6f47fb6a1c | |||
| 6150720b1e | |||
| dcdeea19ab | |||
| ca56d47110 | |||
| 17b548d679 | |||
| 20eeaa58e9 | |||
| 6621c10ae0 | |||
| 75214354ca | |||
| 84d99e8520 | |||
| 4b80eabb73 | |||
| e6830bbd0e | |||
| 97c751f171 | |||
| dc814e08e7 | |||
| 54bf7c204a | |||
| 36c9983c9e | |||
| 361e0489ed | |||
| 77436120a8 | |||
| 2d7ab5411f | |||
| 1ce523b252 | |||
| bb81cf06ae | |||
| f7f6ac9494 | |||
| 481958f4d3 | |||
| 5e3502cbc5 | |||
| 3b03c30865 | |||
| c9e16a7f74 | |||
| f9b6793d47 | |||
| cfbd267d3a | |||
| 2b6f01bf6f | |||
| a44d43eefc | |||
| 0e138bac7d | |||
| 71876febf8 | |||
| 489c786c1e | |||
| eb0c61e173 | |||
| 9341c21963 | |||
| 704c483074 | |||
| 1f0e667085 | |||
| 0b72b73d1d | |||
| 32dd1689d0 | |||
| 2e4d19671a | |||
| 5b4d88ca26 | |||
| 65cc3954b6 | |||
| ffe5544f9c | |||
| bf1ad07a9c | |||
| 338566c1d6 | |||
| 91ea7a443c | |||
| aea96d5eba | |||
| e29f08264c | |||
| ad84ecf6a4 | |||
| ae57a0ff7c | |||
| 1daff4178d | |||
| 3a2b119618 | |||
| 5f27426f44 |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -1,30 +0,0 @@
|
|||||||
name: Docker CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Get release version
|
|
||||||
id: get_version
|
|
||||||
uses: dawidd6/action-get-tag@v1
|
|
||||||
|
|
||||||
- name: Login
|
|
||||||
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: docker build -t tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }} -t tyrrrz/discordchatexporter:stable .
|
|
||||||
|
|
||||||
- name: Deploy (version)
|
|
||||||
run: docker push tyrrrz/discordchatexporter:${{ steps.get_version.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Deploy (stable)
|
|
||||||
run: docker push tyrrrz/discordchatexporter:stable
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
name: CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Get release version
|
|
||||||
id: get_version
|
|
||||||
uses: dawidd6/action-get-tag@v1
|
|
||||||
|
|
||||||
- name: Install .NET
|
|
||||||
uses: actions/setup-dotnet@v1.7.2
|
|
||||||
with:
|
|
||||||
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
|
||||||
dotnet-version: 6.0.100
|
|
||||||
|
|
||||||
- name: Build & publish (CLI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Build & publish (GUI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Pack (CLI)
|
|
||||||
run: Compress-Archive -Path DiscordChatExporter.Cli/bin/Publish/* -DestinationPath DiscordChatExporter.Cli/bin/Publish/Archive.zip -Force
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Pack (GUI)
|
|
||||||
run: Compress-Archive -Path DiscordChatExporter.Gui/bin/Publish/* -DestinationPath DiscordChatExporter.Gui/bin/Publish/Archive.zip -Force
|
|
||||||
shell: pwsh
|
|
||||||
|
|
||||||
- name: Create release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1.0.1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref }}
|
|
||||||
release_name: ${{ github.ref }}
|
|
||||||
body: |
|
|
||||||
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
|
||||||
draft: false
|
|
||||||
prerelease: false
|
|
||||||
|
|
||||||
- name: Upload release asset (CLI)
|
|
||||||
uses: actions/upload-release-asset@v1.0.2
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: DiscordChatExporter.Cli/bin/Publish/Archive.zip
|
|
||||||
asset_name: DiscordChatExporter.CLI.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Upload release asset (GUI)
|
|
||||||
uses: actions/upload-release-asset@v1.0.2
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: DiscordChatExporter.Gui/bin/Publish/Archive.zip
|
|
||||||
asset_name: DiscordChatExporter.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Notify Discord
|
|
||||||
uses: satak/webrequest-action@v1.2.4
|
|
||||||
with:
|
|
||||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
|
||||||
method: POST
|
|
||||||
headers: |
|
|
||||||
{
|
|
||||||
"ContentType": "application/json; charset=UTF-8"
|
|
||||||
}
|
|
||||||
payload: |
|
|
||||||
{
|
|
||||||
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
name: Docker CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Login
|
|
||||||
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: docker build -t tyrrrz/discordchatexporter:latest .
|
|
||||||
|
|
||||||
- name: Deploy (latest)
|
|
||||||
run: docker push tyrrrz/discordchatexporter:latest
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: windows-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v2.3.3
|
|
||||||
|
|
||||||
- name: Install .NET
|
|
||||||
uses: actions/setup-dotnet@v1.7.2
|
|
||||||
with:
|
|
||||||
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
|
||||||
dotnet-version: 6.0.100
|
|
||||||
|
|
||||||
- name: Build & test
|
|
||||||
# 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
|
|
||||||
env:
|
|
||||||
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
|
||||||
DISCORD_TOKEN_BOT: true
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: codecov/codecov-action@v1.0.5
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build & publish (CLI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Build & publish (GUI)
|
|
||||||
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
|
||||||
|
|
||||||
- name: Upload build artifacts (CLI)
|
|
||||||
uses: actions/upload-artifact@v1
|
|
||||||
with:
|
|
||||||
name: DiscordChatExporter.CLI
|
|
||||||
path: DiscordChatExporter.Cli/bin/Publish/
|
|
||||||
|
|
||||||
- name: Upload build artifact (GUI)
|
|
||||||
uses: actions/upload-artifact@v1
|
|
||||||
with:
|
|
||||||
name: DiscordChatExporter
|
|
||||||
path: DiscordChatExporter.Gui/bin/Publish/
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
name: docker
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Build container
|
||||||
|
run: docker build -f DiscordChatExporter.Cli.dockerfile .
|
||||||
|
|
||||||
|
deploy-latest:
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref_type == 'branch' && github.ref_name == 'master' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build container
|
||||||
|
run: docker build -f DiscordChatExporter.Cli.dockerfile -t tyrrrz/discordchatexporter:latest .
|
||||||
|
|
||||||
|
- name: Push container
|
||||||
|
run: docker push tyrrrz/discordchatexporter:latest
|
||||||
|
|
||||||
|
deploy-stable:
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Get release version
|
||||||
|
id: get_version
|
||||||
|
uses: dawidd6/action-get-tag@v1
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
run: docker login -u tyrrrz -p ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
|
||||||
|
- 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 }}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
name: main
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||||
|
DOTNET_NOLOGO: true
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install .NET
|
||||||
|
uses: actions/setup-dotnet@v2
|
||||||
|
with:
|
||||||
|
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||||
|
dotnet-version: 6.0.100
|
||||||
|
|
||||||
|
- 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
|
||||||
|
env:
|
||||||
|
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
||||||
|
DISCORD_TOKEN_BOT: true
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: test
|
||||||
|
runs-on: windows-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||||
|
DOTNET_NOLOGO: true
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install .NET
|
||||||
|
uses: actions/setup-dotnet@v2
|
||||||
|
with:
|
||||||
|
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||||
|
dotnet-version: 6.0.100
|
||||||
|
|
||||||
|
- name: Publish (CLI)
|
||||||
|
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
|
- name: Publish (GUI)
|
||||||
|
run: dotnet publish DiscordChatExporter.Gui/ -o DiscordChatExporter.Gui/bin/Publish/ --configuration Release
|
||||||
|
|
||||||
|
- name: Upload artifacts (CLI)
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter.Cli
|
||||||
|
path: DiscordChatExporter.Cli/bin/Publish/
|
||||||
|
|
||||||
|
- name: Upload artifacts (GUI)
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter
|
||||||
|
path: DiscordChatExporter.Gui/bin/Publish/
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.event_name == 'push' && github.ref_type == 'tag' }}
|
||||||
|
needs: publish
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts (CLI)
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter.Cli
|
||||||
|
path: DiscordChatExporter.Cli
|
||||||
|
|
||||||
|
- name: Download artifacts (GUI)
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: DiscordChatExporter
|
||||||
|
path: DiscordChatExporter.Gui
|
||||||
|
|
||||||
|
- name: Create package (CLI)
|
||||||
|
run: Compress-Archive -Path DiscordChatExporter.Cli/* -DestinationPath DiscordChatExporter.Cli.zip -Force
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Create package (GUI)
|
||||||
|
run: Compress-Archive -Path DiscordChatExporter.Gui/* -DestinationPath DiscordChatExporter.Gui.zip -Force
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref }}
|
||||||
|
release_name: ${{ github.ref }}
|
||||||
|
body: |
|
||||||
|
[Changelog](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md)
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload release asset (CLI)
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_name: DiscordChatExporter.Cli.zip
|
||||||
|
asset_path: DiscordChatExporter.Cli.zip
|
||||||
|
asset_content_type: application/zip
|
||||||
|
|
||||||
|
- name: Upload release asset (GUI)
|
||||||
|
uses: actions/upload-release-asset@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_name: DiscordChatExporter.zip
|
||||||
|
asset_path: DiscordChatExporter.Gui.zip
|
||||||
|
asset_content_type: application/zip
|
||||||
|
|
||||||
|
notify:
|
||||||
|
needs: deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Get release version
|
||||||
|
id: get_version
|
||||||
|
uses: dawidd6/action-get-tag@v1
|
||||||
|
|
||||||
|
- name: Notify Discord
|
||||||
|
uses: satak/webrequest-action@v1.2.4
|
||||||
|
with:
|
||||||
|
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
method: POST
|
||||||
|
headers: |
|
||||||
|
{
|
||||||
|
"ContentType": "application/json; charset=UTF-8"
|
||||||
|
}
|
||||||
|
payload: |
|
||||||
|
{
|
||||||
|
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
@@ -1,3 +1,40 @@
|
|||||||
|
### v2.35 (02-Jul-2022)
|
||||||
|
|
||||||
|
- Added support for exporting text chat in voice channels. These channels are now also displayed when browsing in the GUI and in the CLI when executing the `channels` command.
|
||||||
|
- [GUI] Direct message channels are now sorted by the timestamp of their last message, similarly to how they appear in the Discord client.
|
||||||
|
- [GUI] Removed artificial grouping of direct message channels into "Private" and "Group".
|
||||||
|
- [CLI] Added runtime bootstrapper capabilities to the CLI flavor of DiscordChatExporter on Windows. Now, if you attempt to run the application without the required prerequisites installed, you will be provided with an option to install them automatically.
|
||||||
|
- [CLI] Fixed outdated information in the usage guide. (Thanks [@Clint Herron](https://github.com/HanClinto))
|
||||||
|
- [CLI] Added whitespace padding to the output of `guilds`, `channels`, and `dm` to make the tables look more uniform.
|
||||||
|
- [HTML] Added hyperlinks to message timestamps, which allows you to quickly copy a link to a specific message in the export. (Thanks [@Philipp C. Heckel](https://github.com/binwiederhier))
|
||||||
|
- [HTML] Added minification. Chat exports in HTML format are now 30-35% smaller than before.
|
||||||
|
- [HTML] Added support for rendering embeds of Twitter posts that contain multiple images. Previously, this resulted in multiple separate embeds instead of one.
|
||||||
|
- [HTML] Added support for rendering embeds of YouTube Shorts videos.
|
||||||
|
- [HTML] Text content is now hidden if the message only contains a link to an image and nothing else. The link itself is resolved as an image embed.
|
||||||
|
- [HTML] Fixed an issue where some emoji that included a zero-width-joiner rune were not rendered correctly due to a wrong Twemoji URL being generated. (Thanks [@Ethan](https://github.com/ethanndickson))
|
||||||
|
- [HTML] Fixed an issue where replies to a message that contained a quote (i.e. `>`) were not rendered correctly.
|
||||||
|
- [HTML] Fixed an issue where code blocks with language highlighting did not have the correct background color.
|
||||||
|
- Added file name template token that resolves to the current date (`%d`). (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||||
|
- Updated the usage guide to replace the instructions for retrieving the user token with those that appear to work for more people.
|
||||||
|
- Updated the usage guide to feature the TOS warning more prominently.
|
||||||
|
|
||||||
|
### v2.34.1 (01-Jun-2022)
|
||||||
|
|
||||||
|
- Improved retry policy to handle more transient errors. This should result in fewer errors exporting a channel due to timeout.
|
||||||
|
- Added `has:pin` message filter. You can use it to limit exported messages to those that have been pinned. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||||
|
- Fixed an issue which caused the export to fail when processing stickers that have no name.
|
||||||
|
|
||||||
|
### v2.34 (10-Apr-2022)
|
||||||
|
|
||||||
|
- [HTML] Reworked layout to be more consistent with Discord.
|
||||||
|
- [HTML] Changed user profile picture dimensions from `128x128` to `512x512`. (Thanks [@Chris Helder](https://github.com/TheDude53))
|
||||||
|
- [HTML] Improved image scaling for Chromium-based browsers. This should result in higher quality images across the entire export.
|
||||||
|
- [HTML] Hovering over messages will now show short timestamps on the left, indicating the time that the individual messages of the group were sent.
|
||||||
|
- [HTML] Using "download media" option will now also download scripts, styles, fonts, and other similar resources used by the export.
|
||||||
|
- [HTML] Image attachments will now use the provided description as alt text.
|
||||||
|
- [HTML] Fixed a few layout inconsistencies related to embeds.
|
||||||
|
- [GUI] Added a few clickable links to the usage guide to make the process of locating tokens easier.
|
||||||
|
|
||||||
### v2.33.2 (07-Mar-2022)
|
### v2.33.2 (07-Mar-2022)
|
||||||
|
|
||||||
- Actually fixed it this time.
|
- Actually fixed it this time.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<Version>2.33.2</Version>
|
<Version>2.35</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<CollectCoverage>true</CollectCoverage>
|
|
||||||
<CoverletOutputFormat>opencover</CoverletOutputFormat>
|
|
||||||
<NoWarn>$(NoWarn);xUnit1013</NoWarn>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
|
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="0.16.1" />
|
<PackageReference Include="AngleSharp" Version="0.17.1" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.5.1" />
|
<PackageReference Include="FluentAssertions" Version="6.7.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="1.3.0" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
|
||||||
<PackageReference Include="coverlet.msbuild" Version="3.1.2" PrivateAssets="all" />
|
|
||||||
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
||||||
<PackageReference Include="xunit" Version="2.4.1" />
|
<PackageReference Include="xunit" Version="2.4.1" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -46,58 +46,46 @@ public class ExportWrapperFixture : IDisposable
|
|||||||
return await File.ReadAllTextAsync(filePath);
|
return await File.ReadAllTextAsync(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId)
|
public async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse(
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.HtmlDark)
|
||||||
var data = await ExportAsync(channelId, ExportFormat.HtmlDark);
|
);
|
||||||
return Html.Parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId)
|
public async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId) => Json.Parse(
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.Json)
|
||||||
var data = await ExportAsync(channelId, ExportFormat.Json);
|
);
|
||||||
return Json.Parse(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId)
|
public async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId) =>
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.PlainText);
|
||||||
var data = await ExportAsync(channelId, ExportFormat.PlainText);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<string> ExportAsCsvAsync(Snowflake channelId)
|
public async ValueTask<string> ExportAsCsvAsync(Snowflake channelId) =>
|
||||||
{
|
await ExportAsync(channelId, ExportFormat.Csv);
|
||||||
var data = await ExportAsync(channelId, ExportFormat.Csv);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId)
|
public async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId) =>
|
||||||
{
|
(await ExportAsHtmlAsync(channelId))
|
||||||
var document = await ExportAsHtmlAsync(channelId);
|
.QuerySelectorAll("[data-message-id]")
|
||||||
return document.QuerySelectorAll("[data-message-id]").ToArray();
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId)
|
public async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId) =>
|
||||||
{
|
(await ExportAsJsonAsync(channelId))
|
||||||
var document = await ExportAsJsonAsync(channelId);
|
.GetProperty("messages")
|
||||||
return document.GetProperty("messages").EnumerateArray().ToArray();
|
.EnumerateArray()
|
||||||
}
|
.ToArray();
|
||||||
|
|
||||||
public async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
|
public async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
|
||||||
{
|
{
|
||||||
var messages = await GetMessagesAsHtmlAsync(channelId);
|
var message = (await GetMessagesAsHtmlAsync(channelId))
|
||||||
|
.SingleOrDefault(e =>
|
||||||
var message = messages.SingleOrDefault(e =>
|
string.Equals(
|
||||||
string.Equals(
|
e.GetAttribute("data-message-id"),
|
||||||
e.GetAttribute("data-message-id"),
|
messageId.ToString(),
|
||||||
messageId.ToString(),
|
StringComparison.OrdinalIgnoreCase
|
||||||
StringComparison.OrdinalIgnoreCase
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (message is null)
|
if (message is null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
$"Message '{messageId}' does not exist in the export of channel '{channelId}'."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,20 +94,19 @@ public class ExportWrapperFixture : IDisposable
|
|||||||
|
|
||||||
public async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
|
public async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
|
||||||
{
|
{
|
||||||
var messages = await GetMessagesAsJsonAsync(channelId);
|
var message = (await GetMessagesAsJsonAsync(channelId))
|
||||||
|
.SingleOrDefault(j =>
|
||||||
var message = messages.FirstOrDefault(j =>
|
string.Equals(
|
||||||
string.Equals(
|
j.GetProperty("id").GetString(),
|
||||||
j.GetProperty("id").GetString(),
|
messageId.ToString(),
|
||||||
messageId.ToString(),
|
StringComparison.OrdinalIgnoreCase
|
||||||
StringComparison.OrdinalIgnoreCase
|
)
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|
||||||
if (message.ValueKind == JsonValueKind.Undefined)
|
if (message.ValueKind == JsonValueKind.Undefined)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Message '{messageId}' does not exist in export of channel '{channelId}'."
|
$"Message '{messageId}' does not exist in the export of channel '{channelId}'."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,11 @@
|
|||||||
This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios.
|
This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios.
|
||||||
In order to run these tests locally, you need to join the test server and configure your authentication token.
|
In order to run these tests locally, you need to join the test server and configure your authentication token.
|
||||||
|
|
||||||
1. Join the server: https://discord.gg/eRV8Vap5bm
|
1. [Join the test server](https://discord.gg/eRV8Vap5bm)
|
||||||
2. Locate your Discord authentication token
|
2. Locate your Discord authentication token
|
||||||
3. Specify your token using a file or an environment variable:
|
3. Specify your token using a file or an environment variable:
|
||||||
- **Using a file**: put your token in a new `DiscordToken.secret` file created in this directory
|
- **Using a file**: put your token in a new `DiscordToken.secret` file created in this directory
|
||||||
- **Using an environment variable**: set `DISCORD_TOKEN` variable to your token
|
- **Using an environment variable**: set `DISCORD_TOKEN` variable to your token
|
||||||
4. Run the tests: `dotnet test`
|
4. Run the tests: `dotnet test`
|
||||||
|
|
||||||
> If you want to have a new test case or a scenario added, please let me know in your pull request.
|
> If you want to add a new test case, please let me know and I will give you the required permissions
|
||||||
Currently, it's not possible to add them by yourself.
|
|
||||||
@@ -6,13 +6,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.CsvWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.CsvWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
var document = await _exportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document.Should().ContainAll(
|
document.Should().ContainAll(
|
||||||
|
|||||||
@@ -15,14 +15,21 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class DateRangeSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public DateRangeSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date()
|
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -34,16 +41,14 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
After = Snowflake.FromDate(after)
|
After = Snowflake.FromDate(after)
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
var timestamps = Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
var timestamps = document
|
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// Assert
|
|
||||||
timestamps.All(t => t > after).Should().BeTrue();
|
timestamps.All(t => t > after).Should().BeTrue();
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
@@ -68,7 +73,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -80,16 +85,14 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
Before = Snowflake.FromDate(before)
|
Before = Snowflake.FromDate(before)
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
var timestamps = Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
var timestamps = document
|
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// Assert
|
|
||||||
timestamps.All(t => t < before).Should().BeTrue();
|
timestamps.All(t => t < before).Should().BeTrue();
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
@@ -113,7 +116,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
// Arrange
|
// Arrange
|
||||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||||
var before = new DateTimeOffset(2021, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
var before = new DateTimeOffset(2021, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -126,16 +129,14 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
After = Snowflake.FromDate(after)
|
After = Snowflake.FromDate(after)
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
// Assert
|
||||||
var document = Json.Parse(data);
|
var timestamps = Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
var timestamps = document
|
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
// Assert
|
|
||||||
timestamps.All(t => t < before && t > after).Should().BeTrue();
|
timestamps.All(t => t < before && t > after).Should().BeTrue();
|
||||||
|
|
||||||
timestamps.Should().BeEquivalentTo(new[]
|
timestamps.Should().BeEquivalentTo(new[]
|
||||||
|
|||||||
@@ -14,13 +14,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class FilterSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public FilterSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text()
|
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -32,11 +39,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("some text")
|
MessageFilter = MessageFilter.Parse("some text")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
@@ -48,7 +53,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author()
|
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -60,11 +65,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
||||||
@@ -76,7 +79,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content()
|
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -88,11 +91,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("has:image")
|
MessageFilter = MessageFilter.Parse("has:image")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
@@ -100,11 +101,37 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
.ContainSingle("This has image");
|
.ContainSingle("This has image");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Messages_filtered_by_pin_only_include_messages_that_have_been_pinned()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = filePath,
|
||||||
|
MessageFilter = MessageFilter.Parse("has:pin")
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
|
.Should()
|
||||||
|
.ContainSingle("This is pinned");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
|
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
@@ -116,11 +143,9 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Json.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Json
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.GetProperty("messages")
|
.GetProperty("messages")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("content").GetString())
|
.Select(j => j.GetProperty("content").GetString())
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
using DiscordChatExporter.Cli.Tests.Fixtures;
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
using DiscordChatExporter.Cli.Tests.TestData;
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
@@ -8,19 +9,24 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class AttachmentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public AttachmentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885587844989612074")
|
Snowflake.Parse("885587844989612074")
|
||||||
);
|
);
|
||||||
|
|
||||||
var fileUrl = message.QuerySelector("a")?.GetAttribute("href");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().ContainAll(
|
message.Text().Should().ContainAll(
|
||||||
"Generic file attachment",
|
"Generic file attachment",
|
||||||
@@ -28,45 +34,52 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
"11 bytes"
|
"11 bytes"
|
||||||
);
|
);
|
||||||
|
|
||||||
fileUrl.Should().StartWithEquivalentOf(
|
message
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
.QuerySelectorAll("a")
|
||||||
);
|
.Select(e => e.GetAttribute("href"))
|
||||||
|
.Should()
|
||||||
|
.Contain(
|
||||||
|
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885654862656843786")
|
Snowflake.Parse("885654862656843786")
|
||||||
);
|
);
|
||||||
|
|
||||||
var imageUrl = message.QuerySelector("img")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Image attachment");
|
message.Text().Should().Contain("Image attachment");
|
||||||
|
|
||||||
imageUrl.Should().StartWithEquivalentOf(
|
message
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
.QuerySelectorAll("img")
|
||||||
);
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.Should()
|
||||||
|
.Contain(
|
||||||
|
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885655761919836171")
|
Snowflake.Parse("885655761919836171")
|
||||||
);
|
);
|
||||||
|
|
||||||
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Video attachment");
|
message.Text().Should().Contain("Video attachment");
|
||||||
|
|
||||||
videoUrl.Should().StartWithEquivalentOf(
|
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
|
||||||
|
videoUrl.Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -74,18 +87,19 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885656175620808734")
|
Snowflake.Parse("885656175620808734")
|
||||||
);
|
);
|
||||||
|
|
||||||
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Audio attachment");
|
message.Text().Should().Contain("Audio attachment");
|
||||||
|
|
||||||
audioUrl.Should().StartWithEquivalentOf(
|
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
|
||||||
|
audioUrl.Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
var messages = await _exportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal(
|
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal(
|
||||||
@@ -28,7 +35,7 @@ public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
"885169254029213696"
|
"885169254029213696"
|
||||||
);
|
);
|
||||||
|
|
||||||
messages.Select(e => e.QuerySelector(".chatlog__content")?.Text().Trim()).Should().Equal(
|
messages.SelectMany(e => e.Text()).Should().ContainInOrder(
|
||||||
"Hello world",
|
"Hello world",
|
||||||
"Goodbye world",
|
"Goodbye world",
|
||||||
"Foo bar",
|
"Foo bar",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
using DiscordChatExporter.Cli.Tests.Fixtures;
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
using DiscordChatExporter.Cli.Tests.TestData;
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
@@ -8,13 +9,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public EmbedSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_embed_is_rendered_correctly()
|
public async Task Message_with_an_embed_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("866769910729146400")
|
Snowflake.Parse("866769910729146400")
|
||||||
);
|
);
|
||||||
@@ -32,32 +40,95 @@ public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_Spotify_track_is_rendered_using_an_iframe()
|
public async Task Message_containing_an_image_link_is_rendered_with_an_image_embed()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/537
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("991768701126852638")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.QuerySelectorAll("img")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.Should()
|
||||||
|
.Contain("https://i.redd.it/f8w05ja8s4e61.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_an_image_link_and_nothing_else_is_rendered_without_text_content()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("991768701126852638")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var content = message.QuerySelector(".chatlog__content")?.Text();
|
||||||
|
content.Should().BeNullOrEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/657
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("867886632203976775")
|
Snowflake.Parse("867886632203976775")
|
||||||
);
|
);
|
||||||
|
|
||||||
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
iframeSrc.Should().StartWithEquivalentOf("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||||
|
iframeUrl.Should().Be("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_YouTube_video_is_rendered_using_an_iframe()
|
public async Task Message_containing_a_YouTube_video_link_is_rendered_with_a_video_embed()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/570
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("866472508588294165")
|
Snowflake.Parse("866472508588294165")
|
||||||
);
|
);
|
||||||
|
|
||||||
var iframeSrc = message.QuerySelector("iframe")?.GetAttribute("src");
|
// Assert
|
||||||
|
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||||
|
iframeUrl.Should().Be("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_containing_a_Twitter_post_link_with_multiple_images_is_rendered_as_a_single_embed()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.EmbedTestCases,
|
||||||
|
Snowflake.Parse("991757444017557665")
|
||||||
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
iframeSrc.Should().StartWithEquivalentOf("https://www.youtube.com/embed/qOWW4OlgbvE");
|
message
|
||||||
|
.QuerySelectorAll("img")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.Should()
|
||||||
|
.ContainInOrder(
|
||||||
|
"https://images-ext-1.discordapp.net/external/-n--xW3EHH_3jlrheVkMXHCM7T86b5Ty4-MzXCT4m1Q/https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
|
||||||
|
"https://images-ext-2.discordapp.net/external/z5nEmGeEldV-kswydGLhqUsFHbb5AWHtdvc9XT6N5rE/https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
|
||||||
|
"https://images-ext-2.discordapp.net/external/gnip03SawMB6uZLagN5sRDpA_1Ap1CcEhMbJfK1z6WQ/https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
|
||||||
|
"https://images-ext-2.discordapp.net/external/jl1v6cCbLaGmiwmKU-ZkXnF4cFsJ39f9A3-oEdqPdZs/https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png"
|
||||||
|
);
|
||||||
|
|
||||||
|
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using AngleSharp.Dom;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using DiscordChatExporter.Cli.Commands;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public class GroupingSpecs : IClassFixture<TempOutputFixture>
|
||||||
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public GroupingSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Messages_are_grouped_correctly()
|
||||||
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/152
|
||||||
|
|
||||||
|
// Arrange
|
||||||
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = new[] { ChannelIds.GroupingTestCases },
|
||||||
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
|
OutputPath = filePath
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var messageGroups = Html
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
|
.QuerySelectorAll(".chatlog__message-group");
|
||||||
|
|
||||||
|
messageGroups.Should().HaveCount(2);
|
||||||
|
|
||||||
|
messageGroups[0]
|
||||||
|
.QuerySelectorAll(".chatlog__content")
|
||||||
|
.Select(e => e.Text())
|
||||||
|
.Should()
|
||||||
|
.ContainInOrder(
|
||||||
|
"First",
|
||||||
|
"Second",
|
||||||
|
"Third",
|
||||||
|
"Fourth",
|
||||||
|
"Fifth",
|
||||||
|
"Sixth",
|
||||||
|
"Seventh",
|
||||||
|
"Eighth",
|
||||||
|
"Ninth",
|
||||||
|
"Tenth"
|
||||||
|
);
|
||||||
|
|
||||||
|
messageGroups[1]
|
||||||
|
.QuerySelectorAll(".chatlog__content")
|
||||||
|
.Select(e => e.Text())
|
||||||
|
.Should()
|
||||||
|
.ContainInOrder(
|
||||||
|
"Eleventh",
|
||||||
|
"Twelveth",
|
||||||
|
"Thirteenth",
|
||||||
|
"Fourteenth",
|
||||||
|
"Fifteenth"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,19 +8,26 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class MentionSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public MentionSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task User_mention_is_rendered_correctly()
|
public async Task User_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866458840245076028")
|
Snowflake.Parse("866458840245076028")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("User mention: @Tyrrrz");
|
message.Text().Should().Contain("User mention: @Tyrrrz");
|
||||||
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,38 +35,38 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
public async Task Text_channel_mention_is_rendered_correctly()
|
public async Task Text_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459040480624680")
|
Snowflake.Parse("866459040480624680")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Text channel mention: #mention-tests");
|
message.Text().Should().Contain("Text channel mention: #mention-tests");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Voice_channel_mention_is_rendered_correctly()
|
public async Task Voice_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459175462633503")
|
Snowflake.Parse("866459175462633503")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc");
|
message.Text().Should().Contain("Voice channel mention: 🔊chaos-vc");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Role_mention_is_rendered_correctly()
|
public async Task Role_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459254693429258")
|
Snowflake.Parse("866459254693429258")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Role mention: @Role 1");
|
message.Text().Should().Contain("Role mention: @Role 1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,49 +8,60 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ReplySpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ReplySpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_normal_message_is_rendered_correctly()
|
public async Task Reply_to_a_normal_message_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866460738239725598")
|
Snowflake.Parse("866460738239725598")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to original");
|
message.Text().Should().Contain("reply to original");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original");
|
message.QuerySelector(".chatlog__reference-link")?.Text().Should().Contain("original");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_deleted_message_is_rendered_correctly()
|
public async Task Reply_to_a_deleted_message_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/645
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866460975388819486")
|
Snowflake.Parse("866460975388819486")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to deleted");
|
message.Text().Should().Contain("reply to deleted");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Should().Contain(
|
||||||
.Be("Original message was deleted or could not be loaded.");
|
"Original message was deleted or could not be loaded."
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_empty_message_with_attachment_is_rendered_correctly()
|
public async Task Reply_to_an_empty_message_with_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/634
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.ReplyTestCases,
|
ChannelIds.ReplyTestCases,
|
||||||
Snowflake.Parse("866462470335627294")
|
Snowflake.Parse("866462470335627294")
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to attachment");
|
message.Text().Should().Contain("reply to attachment");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Should().Contain("Click to see attachment");
|
||||||
.Be("Click to see attachment 🖼️");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,39 +7,40 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class StickerSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public StickerSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.StickerTestCases,
|
ChannelIds.StickerTestCases,
|
||||||
Snowflake.Parse("939670623158943754")
|
Snowflake.Parse("939670623158943754")
|
||||||
);
|
);
|
||||||
|
|
||||||
var container = message.QuerySelector("[title='rock']");
|
|
||||||
var sourceUrl = container?.QuerySelector("img")?.GetAttribute("src");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
container.Should().NotBeNull();
|
var stickerUrl = message.QuerySelector("[title='rock'] img")?.GetAttribute("src");
|
||||||
sourceUrl.Should().Be("https://discord.com/stickers/904215665597120572.png");
|
stickerUrl.Should().Be("https://discord.com/stickers/904215665597120572.png");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await _exportWrapper.GetMessageAsHtmlAsync(
|
||||||
ChannelIds.StickerTestCases,
|
ChannelIds.StickerTestCases,
|
||||||
Snowflake.Parse("939670526517997590")
|
Snowflake.Parse("939670526517997590")
|
||||||
);
|
);
|
||||||
|
|
||||||
var container = message.QuerySelector("[title='Yikes']");
|
|
||||||
var sourceUrl = container?.QuerySelector("div[data-source]")?.GetAttribute("data-source");
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
container.Should().NotBeNull();
|
var stickerUrl = message.QuerySelector("[title='Yikes'] [data-source]")?.GetAttribute("data-source");
|
||||||
sourceUrl.Should().Be("https://discord.com/stickers/816087132447178774.json");
|
stickerUrl.Should().Be("https://discord.com/stickers/816087132447178774.json");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,24 +8,30 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class AttachmentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public AttachmentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885587844989612074")
|
Snowflake.Parse("885587844989612074")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Generic file attachment");
|
message.GetProperty("content").GetString().Should().Be("Generic file attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
|
||||||
@@ -36,18 +42,17 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
public async Task Message_with_an_image_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885654862656843786")
|
Snowflake.Parse("885654862656843786")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Image attachment");
|
message.GetProperty("content").GetString().Should().Be("Image attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
|
||||||
@@ -58,18 +63,17 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
public async Task Message_with_a_video_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885655761919836171")
|
Snowflake.Parse("885655761919836171")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Video attachment");
|
message.GetProperty("content").GetString().Should().Be("Video attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
|
||||||
@@ -80,18 +84,17 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.AttachmentTestCases,
|
ChannelIds.AttachmentTestCases,
|
||||||
Snowflake.Parse("885656175620808734")
|
Snowflake.Parse("885656175620808734")
|
||||||
);
|
);
|
||||||
|
|
||||||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Audio attachment");
|
message.GetProperty("content").GetString().Should().Be("Audio attachment");
|
||||||
|
|
||||||
|
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||||
attachments.Should().HaveCount(1);
|
attachments.Should().HaveCount(1);
|
||||||
attachments.Single().GetProperty("url").GetString().Should().StartWithEquivalentOf(
|
attachments.Single().GetProperty("url").GetString().Should().Be(
|
||||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||||
);
|
);
|
||||||
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
var messages = await _exportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal(
|
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal(
|
||||||
|
|||||||
@@ -8,41 +8,47 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record EmbedSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public EmbedSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_an_embed_is_rendered_correctly()
|
public async Task Message_with_an_embed_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.EmbedTestCases,
|
ChannelIds.EmbedTestCases,
|
||||||
Snowflake.Parse("866769910729146400")
|
Snowflake.Parse("866769910729146400")
|
||||||
);
|
);
|
||||||
|
|
||||||
var embed = message
|
|
||||||
.GetProperty("embeds")
|
|
||||||
.EnumerateArray()
|
|
||||||
.Single();
|
|
||||||
|
|
||||||
var embedAuthor = embed.GetProperty("author");
|
|
||||||
var embedThumbnail = embed.GetProperty("thumbnail");
|
|
||||||
var embedFooter = embed.GetProperty("footer");
|
|
||||||
var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
var embed = message.GetProperty("embeds").EnumerateArray().Single();
|
||||||
embed.GetProperty("title").GetString().Should().Be("Embed title");
|
embed.GetProperty("title").GetString().Should().Be("Embed title");
|
||||||
embed.GetProperty("url").GetString().Should().Be("https://example.com");
|
embed.GetProperty("url").GetString().Should().Be("https://example.com");
|
||||||
embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
|
embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
|
||||||
embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
|
embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
|
||||||
embed.GetProperty("color").GetString().Should().Be("#58B9FF");
|
embed.GetProperty("color").GetString().Should().Be("#58B9FF");
|
||||||
|
|
||||||
|
var embedAuthor = embed.GetProperty("author");
|
||||||
embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
|
embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
|
||||||
embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
|
embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
|
||||||
embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
var embedThumbnail = embed.GetProperty("thumbnail");
|
||||||
embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
|
embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
|
embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
|
||||||
embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);
|
embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);
|
||||||
|
|
||||||
|
var embedFooter = embed.GetProperty("footer");
|
||||||
embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
|
embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
|
||||||
embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
|
||||||
embedFields.Should().HaveCount(3);
|
embedFields.Should().HaveCount(3);
|
||||||
embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
|
embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
|
||||||
embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
|
embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class MentionSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public MentionSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task User_mention_is_rendered_correctly()
|
public async Task User_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866458840245076028")
|
Snowflake.Parse("866458840245076028")
|
||||||
);
|
);
|
||||||
@@ -26,14 +33,15 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
.GetProperty("mentions")
|
.GetProperty("mentions")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Select(j => j.GetProperty("id").GetString())
|
.Select(j => j.GetProperty("id").GetString())
|
||||||
.Should().Contain("128178626683338752");
|
.Should()
|
||||||
|
.Contain("128178626683338752");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Text_channel_mention_is_rendered_correctly()
|
public async Task Text_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459040480624680")
|
Snowflake.Parse("866459040480624680")
|
||||||
);
|
);
|
||||||
@@ -46,7 +54,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
public async Task Voice_channel_mention_is_rendered_correctly()
|
public async Task Voice_channel_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459175462633503")
|
Snowflake.Parse("866459175462633503")
|
||||||
);
|
);
|
||||||
@@ -59,7 +67,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
public async Task Role_mention_is_rendered_correctly()
|
public async Task Role_mention_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.MentionTestCases,
|
ChannelIds.MentionTestCases,
|
||||||
Snowflake.Parse("866459254693429258")
|
Snowflake.Parse("866459254693429258")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,23 +8,30 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class StickerSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public StickerSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.StickerTestCases,
|
ChannelIds.StickerTestCases,
|
||||||
Snowflake.Parse("939670623158943754")
|
Snowflake.Parse("939670623158943754")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
var sticker = message
|
var sticker = message
|
||||||
.GetProperty("stickers")
|
.GetProperty("stickers")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Single();
|
.Single();
|
||||||
|
|
||||||
// Assert
|
|
||||||
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
|
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
|
||||||
sticker.GetProperty("name").GetString().Should().Be("rock");
|
sticker.GetProperty("name").GetString().Should().Be("rock");
|
||||||
sticker.GetProperty("format").GetString().Should().Be("PngAnimated");
|
sticker.GetProperty("format").GetString().Should().Be("PngAnimated");
|
||||||
@@ -35,17 +42,17 @@ public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
var message = await _exportWrapper.GetMessageAsJsonAsync(
|
||||||
ChannelIds.StickerTestCases,
|
ChannelIds.StickerTestCases,
|
||||||
Snowflake.Parse("939670526517997590")
|
Snowflake.Parse("939670526517997590")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
var sticker = message
|
var sticker = message
|
||||||
.GetProperty("stickers")
|
.GetProperty("stickers")
|
||||||
.EnumerateArray()
|
.EnumerateArray()
|
||||||
.Single();
|
.Single();
|
||||||
|
|
||||||
// Assert
|
|
||||||
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
|
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
|
||||||
sticker.GetProperty("name").GetString().Should().Be("Yikes");
|
sticker.GetProperty("name").GetString().Should().Be("Yikes");
|
||||||
sticker.GetProperty("format").GetString().Should().Be("Lottie");
|
sticker.GetProperty("format").GetString().Should().Be("Lottie");
|
||||||
|
|||||||
@@ -12,13 +12,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class PartitioningSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public PartitioningSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_partitioned_by_count_are_split_into_multiple_files_correctly()
|
public async Task Messages_partitioned_by_count_are_split_into_a_corresponding_number_of_files()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
@@ -39,10 +46,10 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_partitioned_by_file_size_are_split_into_multiple_files_correctly()
|
public async Task Messages_partitioned_by_file_size_are_split_into_a_corresponding_number_of_files()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
@@ -53,12 +60,12 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
|
|||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
PartitionLimit = PartitionLimit.Parse("20kb")
|
PartitionLimit = PartitionLimit.Parse("1kb")
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
||||||
.Should()
|
.Should()
|
||||||
.HaveCount(3);
|
.HaveCount(8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs.PlainTextWriting;
|
namespace DiscordChatExporter.Cli.Tests.Specs.PlainTextWriting;
|
||||||
|
|
||||||
public record ContentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
public class ContentSpecs : IClassFixture<ExportWrapperFixture>
|
||||||
{
|
{
|
||||||
|
private readonly ExportWrapperFixture _exportWrapper;
|
||||||
|
|
||||||
|
public ContentSpecs(ExportWrapperFixture exportWrapper)
|
||||||
|
{
|
||||||
|
_exportWrapper = exportWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_are_exported_correctly()
|
public async Task Messages_are_exported_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
var document = await _exportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document.Should().ContainAll(
|
document.Should().ContainAll(
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ using Xunit;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutputFixture>
|
public class SelfContainedSpecs : IClassFixture<TempOutputFixture>
|
||||||
{
|
{
|
||||||
|
private readonly TempOutputFixture _tempOutput;
|
||||||
|
|
||||||
|
public SelfContainedSpecs(TempOutputFixture tempOutput)
|
||||||
|
{
|
||||||
|
_tempOutput = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Messages_in_self_contained_export_only_reference_local_file_resources()
|
public async Task Messages_in_self_contained_export_only_reference_local_file_resources()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var filePath = TempOutput.GetTempFilePath();
|
var filePath = _tempOutput.GetTempFilePath();
|
||||||
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
var dirPath = Path.GetDirectoryName(filePath) ?? Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -32,11 +39,9 @@ public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<T
|
|||||||
ShouldDownloadMedia = true
|
ShouldDownloadMedia = true
|
||||||
}.ExecuteAsync(new FakeConsole());
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
var data = await File.ReadAllTextAsync(filePath);
|
|
||||||
var document = Html.Parse(data);
|
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
document
|
Html
|
||||||
|
.Parse(await File.ReadAllTextAsync(filePath))
|
||||||
.QuerySelectorAll("body [src]")
|
.QuerySelectorAll("body [src]")
|
||||||
.Select(e => e.GetAttribute("src")!)
|
.Select(e => e.GetAttribute("src")!)
|
||||||
.Select(f => Path.GetFullPath(f, dirPath))
|
.Select(f => Path.GetFullPath(f, dirPath))
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public static class ChannelIds
|
|||||||
|
|
||||||
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
||||||
|
|
||||||
public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
|
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
|
||||||
|
|
||||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||||
|
|
||||||
@@ -19,4 +19,6 @@ public static class ChannelIds
|
|||||||
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");
|
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");
|
||||||
|
|
||||||
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
|
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
|
||||||
|
|
||||||
|
public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Build
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
|
||||||
|
|
||||||
|
COPY favicon.ico ./
|
||||||
|
COPY NuGet.config ./
|
||||||
|
COPY Directory.Build.props ./
|
||||||
|
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core
|
||||||
|
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli
|
||||||
|
|
||||||
|
RUN dotnet publish DiscordChatExporter.Cli -c Release -o ./publish
|
||||||
|
|
||||||
|
# Run
|
||||||
|
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run
|
||||||
|
|
||||||
|
COPY --from=build ./publish ./
|
||||||
|
|
||||||
|
WORKDIR ./out
|
||||||
|
ENTRYPOINT ["dotnet", "/DiscordChatExporter.Cli.dll"]
|
||||||
@@ -20,34 +20,67 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
|||||||
|
|
||||||
public abstract class ExportCommandBase : TokenCommandBase
|
public abstract class ExportCommandBase : TokenCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
[CommandOption(
|
||||||
|
"output",
|
||||||
|
'o',
|
||||||
|
Description = "Output file or directory path."
|
||||||
|
)]
|
||||||
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
|
public string OutputPath { get; init; } = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Export format.")]
|
[CommandOption(
|
||||||
|
"format",
|
||||||
|
'f',
|
||||||
|
Description = "Export format."
|
||||||
|
)]
|
||||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
[CommandOption("after", Description = "Only include messages sent after this date or message ID.")]
|
[CommandOption(
|
||||||
|
"after",
|
||||||
|
Description = "Only include messages sent after this date or message ID."
|
||||||
|
)]
|
||||||
public Snowflake? After { get; init; }
|
public Snowflake? After { get; init; }
|
||||||
|
|
||||||
[CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
|
[CommandOption(
|
||||||
|
"before",
|
||||||
|
Description = "Only include messages sent before this date or message ID."
|
||||||
|
)]
|
||||||
public Snowflake? Before { get; init; }
|
public Snowflake? Before { get; init; }
|
||||||
|
|
||||||
[CommandOption("partition", 'p', Description = "Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb').")]
|
[CommandOption(
|
||||||
|
"partition",
|
||||||
|
'p',
|
||||||
|
Description = "Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')."
|
||||||
|
)]
|
||||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
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').")]
|
[CommandOption(
|
||||||
|
"filter",
|
||||||
|
Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')."
|
||||||
|
)]
|
||||||
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
||||||
|
|
||||||
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
|
[CommandOption(
|
||||||
|
"parallel",
|
||||||
|
Description = "Limits how many channels can be exported in parallel."
|
||||||
|
)]
|
||||||
public int ParallelLimit { get; init; } = 1;
|
public int ParallelLimit { get; init; } = 1;
|
||||||
|
|
||||||
[CommandOption("media", Description = "Download referenced media content.")]
|
[CommandOption(
|
||||||
|
"media",
|
||||||
|
Description = "Download referenced media content."
|
||||||
|
)]
|
||||||
public bool ShouldDownloadMedia { get; init; }
|
public bool ShouldDownloadMedia { get; init; }
|
||||||
|
|
||||||
[CommandOption("reuse-media", Description = "Reuse already existing media content to skip redundant downloads.")]
|
[CommandOption(
|
||||||
|
"reuse-media",
|
||||||
|
Description = "Reuse already existing media content to skip redundant downloads."
|
||||||
|
)]
|
||||||
public bool ShouldReuseMedia { get; init; }
|
public bool ShouldReuseMedia { get; init; }
|
||||||
|
|
||||||
[CommandOption("dateformat", Description = "Format used when writing dates.")]
|
[CommandOption(
|
||||||
|
"dateformat",
|
||||||
|
Description = "Format used when writing dates."
|
||||||
|
)]
|
||||||
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
||||||
|
|
||||||
private ChannelExporter? _channelExporter;
|
private ChannelExporter? _channelExporter;
|
||||||
@@ -171,8 +204,8 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
{
|
{
|
||||||
// War in Ukraine message
|
// War in Ukraine message
|
||||||
console.Output.WriteLine("========================================================================");
|
console.Output.WriteLine("========================================================================");
|
||||||
console.Output.WriteLine("|| Ukraine is at war! Support my country in its fight for freedom ||");
|
console.Output.WriteLine("|| Ukraine is at war! Support my country in its fight for freedom~ ||");
|
||||||
console.Output.WriteLine("|| Learn more: https://tyrrrz.me ||");
|
console.Output.WriteLine("|| Learn more on my website: https://tyrrrz.me ||");
|
||||||
console.Output.WriteLine("========================================================================");
|
console.Output.WriteLine("========================================================================");
|
||||||
console.Output.WriteLine("");
|
console.Output.WriteLine("");
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,22 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
|||||||
|
|
||||||
public abstract class TokenCommandBase : ICommand
|
public abstract class TokenCommandBase : ICommand
|
||||||
{
|
{
|
||||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
|
[CommandOption(
|
||||||
|
"token",
|
||||||
|
't',
|
||||||
|
IsRequired = true,
|
||||||
|
EnvironmentVariable = "DISCORD_TOKEN",
|
||||||
|
Description = "Authentication token."
|
||||||
|
)]
|
||||||
public string Token { get; init; } = "";
|
public string Token { get; init; } = "";
|
||||||
|
|
||||||
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "This option doesn't do anything. Kept for backwards compatibility.")]
|
|
||||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||||
|
[CommandOption(
|
||||||
|
"bot",
|
||||||
|
'b',
|
||||||
|
EnvironmentVariable = "DISCORD_TOKEN_BOT",
|
||||||
|
Description = "This option doesn't do anything. Kept for backwards compatibility."
|
||||||
|
)]
|
||||||
public bool IsBotToken { get; init; }
|
public bool IsBotToken { get; init; }
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
private DiscordClient? _discordClient;
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
[Command("exportall", Description = "Export all accessible channels.")]
|
[Command("exportall", Description = "Export all accessible channels.")]
|
||||||
public class ExportAllCommand : ExportCommandBase
|
public class ExportAllCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
[CommandOption(
|
||||||
|
"include-dm",
|
||||||
|
Description = "Include direct message channels."
|
||||||
|
)]
|
||||||
public bool IncludeDirectMessages { get; init; } = true;
|
public bool IncludeDirectMessages { get; init; } = true;
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
@@ -28,13 +31,7 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
|
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken))
|
||||||
{
|
|
||||||
// Skip non-text channels
|
|
||||||
if (!channel.IsTextChannel)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
channels.Add(channel);
|
channels.Add(channel);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await base.ExecuteAsync(console, channels);
|
await base.ExecuteAsync(console, channels);
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
public class ExportChannelsCommand : ExportCommandBase
|
public class ExportChannelsCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
// TODO: change this to plural (breaking change)
|
// TODO: change this to plural (breaking change)
|
||||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
|
[CommandOption(
|
||||||
|
"channel",
|
||||||
|
'c',
|
||||||
|
IsRequired = true,
|
||||||
|
Description = "Channel ID(s)."
|
||||||
|
)]
|
||||||
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Linq;
|
using System.Threading.Tasks;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
@@ -19,8 +18,7 @@ public class ExportDirectMessagesCommand : ExportCommandBase
|
|||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
||||||
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
|
||||||
|
|
||||||
await base.ExecuteAsync(console, textChannels);
|
await base.ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
using System.Linq;
|
using System.Threading.Tasks;
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
@@ -11,7 +10,12 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
||||||
public class ExportGuildCommand : ExportCommandBase
|
public class ExportGuildCommand : ExportCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption(
|
||||||
|
"guild",
|
||||||
|
'g',
|
||||||
|
IsRequired = true,
|
||||||
|
Description = "Guild ID."
|
||||||
|
)]
|
||||||
public Snowflake GuildId { get; init; }
|
public Snowflake GuildId { get; init; }
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
@@ -22,8 +26,7 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
||||||
var textChannels = channels.Where(c => c.IsTextChannel).ToArray();
|
|
||||||
|
|
||||||
await base.ExecuteAsync(console, textChannels);
|
await base.ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,25 +12,29 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||||||
[Command("channels", Description = "Get the list of channels in a guild.")]
|
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||||
public class GetChannelsCommand : TokenCommandBase
|
public class GetChannelsCommand : TokenCommandBase
|
||||||
{
|
{
|
||||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
[CommandOption(
|
||||||
|
"guild",
|
||||||
|
'g',
|
||||||
|
IsRequired = true,
|
||||||
|
Description = "Guild ID."
|
||||||
|
)]
|
||||||
public Snowflake GuildId { get; init; }
|
public Snowflake GuildId { get; init; }
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var channels = await Discord.GetGuildChannelsAsync(GuildId, cancellationToken);
|
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||||
|
|
||||||
var textChannels = channels
|
|
||||||
.Where(c => c.IsTextChannel)
|
|
||||||
.OrderBy(c => c.Category.Position)
|
.OrderBy(c => c.Category.Position)
|
||||||
.ThenBy(c => c.Name)
|
.ThenBy(c => c.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
foreach (var channel in textChannels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
// Channel ID
|
// Channel ID
|
||||||
await console.Output.WriteAsync(channel.Id.ToString());
|
await console.Output.WriteAsync(
|
||||||
|
channel.Id.ToString().PadRight(18, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
|||||||
@@ -16,18 +16,17 @@ public class GetDirectMessageChannelsCommand : TokenCommandBase
|
|||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken);
|
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
|
||||||
|
.OrderByDescending(c => c.LastMessageId)
|
||||||
var textChannels = channels
|
|
||||||
.Where(c => c.IsTextChannel)
|
|
||||||
.OrderBy(c => c.Category.Position)
|
|
||||||
.ThenBy(c => c.Name)
|
.ThenBy(c => c.Name)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
foreach (var channel in textChannels)
|
foreach (var channel in channels)
|
||||||
{
|
{
|
||||||
// Channel ID
|
// Channel ID
|
||||||
await console.Output.WriteAsync(channel.Id.ToString());
|
await console.Output.WriteAsync(
|
||||||
|
channel.Id.ToString().PadRight(18, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands;
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
@@ -15,12 +16,17 @@ public class GetGuildsCommand : TokenCommandBase
|
|||||||
{
|
{
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
var guilds = await Discord.GetUserGuildsAsync(cancellationToken);
|
var guilds = (await Discord.GetUserGuildsAsync(cancellationToken))
|
||||||
|
// Show direct messages first
|
||||||
|
.OrderByDescending(g => g.Id == Guild.DirectMessages.Id)
|
||||||
|
.ThenBy(g => g.Name);
|
||||||
|
|
||||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
foreach (var guild in guilds)
|
||||||
{
|
{
|
||||||
// Guild ID
|
// Guild ID
|
||||||
await console.Output.WriteAsync(guild.Id.ToString());
|
await console.Output.WriteAsync(
|
||||||
|
guild.Id.ToString().PadRight(18, ' ')
|
||||||
|
);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||||
|
|||||||
@@ -15,15 +15,15 @@ public class GuideCommand : ICommand
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get user token:");
|
console.Output.WriteLine("To get user token:");
|
||||||
|
|
||||||
|
console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!");
|
||||||
console.Output.WriteLine(" 1. Open Discord in your web browser and login");
|
console.Output.WriteLine(" 1. Open Discord in your web browser and login");
|
||||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
|
console.Output.WriteLine(" 3. Navigate to the Network tab");
|
||||||
console.Output.WriteLine(" 4. Navigate to the Application tab");
|
console.Output.WriteLine(" 4. Press Ctrl+R to reload");
|
||||||
console.Output.WriteLine(" 5. On the left, expand Local Storage and select https://discord.com");
|
console.Output.WriteLine(" 5. Search for a request containing \"messages?limit=50\" or similar");
|
||||||
console.Output.WriteLine(" 6. Type \"token\" into the Filter box");
|
console.Output.WriteLine(" 6. Select the Headers tab on the right");
|
||||||
console.Output.WriteLine(" 7. If the token key does not appear, press Ctrl+R to reload");
|
console.Output.WriteLine(" 7. Scroll down to the Request Headers section");
|
||||||
console.Output.WriteLine(" 8. Copy the value of the token key");
|
console.Output.WriteLine(" 8. Copy the value of the \"authorization\" header");
|
||||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|
||||||
// Bot token
|
// Bot token
|
||||||
@@ -42,7 +42,7 @@ public class GuideCommand : ICommand
|
|||||||
|
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord");
|
||||||
console.Output.WriteLine(" 2. Open Settings");
|
console.Output.WriteLine(" 2. Open Settings");
|
||||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
console.Output.WriteLine(" 3. Go to Advanced section");
|
||||||
console.Output.WriteLine(" 4. Enable Developer Mode");
|
console.Output.WriteLine(" 4. Enable Developer Mode");
|
||||||
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||||
console.Output.WriteLine();
|
console.Output.WriteLine();
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.2.2" />
|
<PackageReference Include="CliFx" Version="2.2.5" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.43.0" />
|
<PackageReference Include="Spectre.Console" Version="0.44.0" />
|
||||||
<PackageReference Include="Gress" Version="2.0.1" />
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
|
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public partial record Attachment(
|
|||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
string Url,
|
string Url,
|
||||||
string FileName,
|
string FileName,
|
||||||
|
string? Description,
|
||||||
int? Width,
|
int? Width,
|
||||||
int? Height,
|
int? Height,
|
||||||
FileSize FileSize) : IHasId
|
FileSize FileSize) : IHasId
|
||||||
@@ -34,11 +35,12 @@ public partial record Attachment
|
|||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
||||||
|
var fileName = json.GetProperty("filename").GetNonNullString();
|
||||||
|
var description = json.GetPropertyOrNull("description")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
||||||
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
||||||
var fileName = json.GetProperty("filename").GetNonNullString();
|
|
||||||
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
||||||
|
|
||||||
return new Attachment(id, url, fileName, width, height, fileSize);
|
return new Attachment(id, url, fileName, description, width, height, fileSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,16 +14,10 @@ public partial record Channel(
|
|||||||
ChannelCategory Category,
|
ChannelCategory Category,
|
||||||
string Name,
|
string Name,
|
||||||
int? Position,
|
int? Position,
|
||||||
string? Topic) : IHasId
|
string? Topic,
|
||||||
|
Snowflake? LastMessageId) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsTextChannel => Kind is
|
public bool SupportsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
||||||
ChannelKind.GuildTextChat or
|
|
||||||
ChannelKind.DirectTextChat or
|
|
||||||
ChannelKind.DirectGroupTextChat or
|
|
||||||
ChannelKind.GuildNews or
|
|
||||||
ChannelKind.GuildStore;
|
|
||||||
|
|
||||||
public bool IsVoiceChannel => !IsTextChannel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record Channel
|
public partial record Channel
|
||||||
@@ -36,7 +30,6 @@ public partial record Channel
|
|||||||
ChannelKind.DirectTextChat => "Private",
|
ChannelKind.DirectTextChat => "Private",
|
||||||
ChannelKind.DirectGroupTextChat => "Group",
|
ChannelKind.DirectGroupTextChat => "Group",
|
||||||
ChannelKind.GuildNews => "News",
|
ChannelKind.GuildNews => "News",
|
||||||
ChannelKind.GuildStore => "Store",
|
|
||||||
_ => "Default"
|
_ => "Default"
|
||||||
},
|
},
|
||||||
null
|
null
|
||||||
@@ -45,9 +38,8 @@ public partial record Channel
|
|||||||
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null)
|
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? positionHint = null)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
|
||||||
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
|
||||||
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
||||||
|
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
// Guild channel
|
// Guild channel
|
||||||
@@ -63,7 +55,16 @@ public partial record Channel
|
|||||||
// Fallback
|
// Fallback
|
||||||
id.ToString();
|
id.ToString();
|
||||||
|
|
||||||
var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
var position =
|
||||||
|
positionHint ??
|
||||||
|
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||||
|
|
||||||
|
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
||||||
|
|
||||||
|
var lastMessageId = json
|
||||||
|
.GetPropertyOrNull("last_message_id")?
|
||||||
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
return new Channel(
|
return new Channel(
|
||||||
id,
|
id,
|
||||||
@@ -72,7 +73,8 @@ public partial record Channel
|
|||||||
category ?? GetFallbackCategory(kind),
|
category ?? GetFallbackCategory(kind),
|
||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
topic
|
topic,
|
||||||
|
lastMessageId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,12 +12,15 @@ public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
|
|||||||
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
|
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
||||||
id.ToString();
|
id.ToString();
|
||||||
|
|
||||||
|
var position =
|
||||||
|
positionHint ??
|
||||||
|
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||||
|
|
||||||
return new ChannelCategory(id, name, position);
|
return new ChannelCategory(id, name, position);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,15 @@
|
|||||||
public enum ChannelKind
|
public enum ChannelKind
|
||||||
{
|
{
|
||||||
GuildTextChat = 0,
|
GuildTextChat = 0,
|
||||||
DirectTextChat,
|
DirectTextChat = 1,
|
||||||
GuildVoiceChat,
|
GuildVoiceChat = 2,
|
||||||
DirectGroupTextChat,
|
DirectGroupTextChat = 3,
|
||||||
GuildCategory,
|
GuildCategory = 4,
|
||||||
GuildNews,
|
GuildNews = 5,
|
||||||
GuildStore
|
GuildNewsThread = 10,
|
||||||
|
GuildPublicThread = 11,
|
||||||
|
GuildPrivateThread = 12,
|
||||||
|
GuildStageVoice = 13,
|
||||||
|
GuildDirectory = 14,
|
||||||
|
GuildForum = 15
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ public partial record Embed(
|
|||||||
string? Description,
|
string? Description,
|
||||||
IReadOnlyList<EmbedField> Fields,
|
IReadOnlyList<EmbedField> Fields,
|
||||||
EmbedImage? Thumbnail,
|
EmbedImage? Thumbnail,
|
||||||
EmbedImage? Image,
|
IReadOnlyList<EmbedImage> Images,
|
||||||
EmbedFooter? Footer)
|
EmbedFooter? Footer)
|
||||||
{
|
{
|
||||||
public PlainImageEmbedProjection? TryGetPlainImage() =>
|
public PlainImageEmbedProjection? TryGetPlainImage() =>
|
||||||
@@ -39,17 +39,28 @@ public partial record Embed
|
|||||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
||||||
var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
||||||
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
|
||||||
|
|
||||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
||||||
var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse);
|
|
||||||
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
|
||||||
|
|
||||||
var fields =
|
var fields =
|
||||||
json.GetPropertyOrNull("fields")?.EnumerateArrayOrNull()?.Select(EmbedField.Parse).ToArray() ??
|
json.GetPropertyOrNull("fields")?.EnumerateArrayOrNull()?.Select(EmbedField.Parse).ToArray() ??
|
||||||
Array.Empty<EmbedField>();
|
Array.Empty<EmbedField>();
|
||||||
|
|
||||||
|
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||||
|
|
||||||
|
// Under the Discord API model, embeds can only have at most one image.
|
||||||
|
// Because of that, embeds that are rendered with multiple images on the client
|
||||||
|
// (e.g. tweet embeds), are exposed from the API as multiple separate embeds.
|
||||||
|
// Our embed model is consistent with the user-facing side of Discord, so images
|
||||||
|
// are stored as an array. The API will only ever return one image, but we deal
|
||||||
|
// 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() ??
|
||||||
|
Array.Empty<EmbedImage>();
|
||||||
|
|
||||||
|
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
|
||||||
|
|
||||||
return new Embed(
|
return new Embed(
|
||||||
title,
|
title,
|
||||||
url,
|
url,
|
||||||
@@ -59,7 +70,7 @@ public partial record Embed
|
|||||||
description,
|
description,
|
||||||
fields,
|
fields,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
image,
|
images,
|
||||||
footer
|
footer
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public partial record YouTubeVideoEmbedProjection(string VideoId)
|
|||||||
public partial record YouTubeVideoEmbedProjection
|
public partial record YouTubeVideoEmbedProjection
|
||||||
{
|
{
|
||||||
// Adapted from YoutubeExplode
|
// Adapted from YoutubeExplode
|
||||||
// https://github.com/Tyrrrz/YoutubeExplode/blob/5be164be20019783913f76fcc98f18c65aebe9f0/YoutubeExplode/Videos/VideoId.cs#L34-L64
|
// https://github.com/Tyrrrz/YoutubeExplode/blob/bc700b631bd105d0be208a88116347034bdca88b/YoutubeExplode/Videos/VideoId.cs#L40-L62
|
||||||
private static string? TryParseVideoId(string embedUrl)
|
private static string? TryParseVideoId(string embedUrl)
|
||||||
{
|
{
|
||||||
// Regular URL
|
// Regular URL
|
||||||
@@ -31,6 +31,12 @@ public partial record YouTubeVideoEmbedProjection
|
|||||||
if (!string.IsNullOrWhiteSpace(embedMatch))
|
if (!string.IsNullOrWhiteSpace(embedMatch))
|
||||||
return 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,19 @@ public partial record Emoji(
|
|||||||
|
|
||||||
public partial record Emoji
|
public partial record Emoji
|
||||||
{
|
{
|
||||||
private static string GetTwemojiName(string name) => string.Join("-",
|
private static string GetTwemojiName(string name)
|
||||||
name
|
{
|
||||||
.GetRunes()
|
var runes = name.GetRunes();
|
||||||
// Variant selector rune is skipped in Twemoji names
|
|
||||||
.Where(r => r.Value != 0xfe0f)
|
return string.Join(
|
||||||
.Select(r => r.Value.ToString("x"))
|
"-",
|
||||||
);
|
// Variant selector rune is skipped in Twemoji names, except when the emoji also contains a zero-width joiner.
|
||||||
|
// VS = 0xfe0f. ZWJ = 0x200d.
|
||||||
|
runes.Any(r => r.Value == 0x200d)
|
||||||
|
? runes.Select(r => r.Value.ToString("x"))
|
||||||
|
: runes.Where(r => r.Value != 0xfe0f).Select(r => r.Value.ToString("x"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated
|
private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated
|
||||||
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
||||||
@@ -58,7 +64,6 @@ public partial record Emoji
|
|||||||
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
|
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
||||||
|
|
||||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
var imageUrl = GetImageUrl(id, name, isAnimated);
|
||||||
|
|
||||||
return new Emoji(
|
return new Emoji(
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
|||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var name = json.GetProperty("name").GetNonNullString();
|
var name = json.GetProperty("name").GetNonNullString();
|
||||||
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
|
|
||||||
|
|
||||||
|
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
||||||
? GetIconUrl(id, iconHash)
|
? GetIconUrl(id, iconHash)
|
||||||
: GetDefaultIconUrl();
|
: GetDefaultIconUrl();
|
||||||
|
|||||||
@@ -27,18 +27,76 @@ public record Message(
|
|||||||
MessageReference? Reference,
|
MessageReference? Reference,
|
||||||
Message? ReferencedMessage) : IHasId
|
Message? ReferencedMessage) : IHasId
|
||||||
{
|
{
|
||||||
|
private static IReadOnlyList<Embed> NormalizeEmbeds(IReadOnlyList<Embed> embeds)
|
||||||
|
{
|
||||||
|
if (embeds.Count <= 1)
|
||||||
|
return embeds;
|
||||||
|
|
||||||
|
// Discord API doesn't support embeds with multiple images, even though the Discord client does.
|
||||||
|
// To work around this, it seems that the API returns multiple consecutive embeds with different images,
|
||||||
|
// which are then merged together on the client. We need to replicate the same behavior ourselves.
|
||||||
|
// Currently, only known case where this workaround is required is Twitter embeds.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
|
||||||
|
|
||||||
|
var normalizedEmbeds = new List<Embed>();
|
||||||
|
|
||||||
|
for (var i = 0; i < embeds.Count; i++)
|
||||||
|
{
|
||||||
|
var embed = embeds[i];
|
||||||
|
|
||||||
|
if (embed.Url?.Contains("://twitter.com/") == true)
|
||||||
|
{
|
||||||
|
// Find embeds with the same URL that only contain a single image and nothing else
|
||||||
|
var trailingEmbeds = embeds
|
||||||
|
.Skip(i + 1)
|
||||||
|
.TakeWhile(e =>
|
||||||
|
e.Url == embed.Url &&
|
||||||
|
e.Timestamp is null &&
|
||||||
|
e.Author is null &&
|
||||||
|
e.Color is null &&
|
||||||
|
string.IsNullOrWhiteSpace(e.Description) &&
|
||||||
|
!e.Fields.Any() &&
|
||||||
|
e.Images.Count == 1 &&
|
||||||
|
e.Footer is null
|
||||||
|
)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (trailingEmbeds.Any())
|
||||||
|
{
|
||||||
|
// Concatenate all images into one embed
|
||||||
|
var images = embed.Images.Concat(trailingEmbeds.SelectMany(e => e.Images)).ToArray();
|
||||||
|
normalizedEmbeds.Add(embed with { Images = images });
|
||||||
|
|
||||||
|
i += trailingEmbeds.Length;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
normalizedEmbeds.Add(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
normalizedEmbeds.Add(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedEmbeds;
|
||||||
|
}
|
||||||
|
|
||||||
public static Message Parse(JsonElement json)
|
public static Message Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
|
var kind = (MessageKind)json.GetProperty("type").GetInt32();
|
||||||
var author = json.GetProperty("author").Pipe(User.Parse);
|
var author = json.GetProperty("author").Pipe(User.Parse);
|
||||||
|
|
||||||
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
|
||||||
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")
|
var callEndedTimestamp = json
|
||||||
?.GetDateTimeOffset();
|
.GetPropertyOrNull("call")?
|
||||||
var kind = (MessageKind)json.GetProperty("type").GetInt32();
|
.GetPropertyOrNull("ended_timestamp")?
|
||||||
|
.GetDateTimeOffset();
|
||||||
|
|
||||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
|
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
|
||||||
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
|
||||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
|
||||||
|
|
||||||
var content = kind switch
|
var content = kind switch
|
||||||
{
|
{
|
||||||
@@ -57,9 +115,10 @@ public record Message(
|
|||||||
json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ??
|
json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ??
|
||||||
Array.Empty<Attachment>();
|
Array.Empty<Attachment>();
|
||||||
|
|
||||||
var embeds =
|
var embeds = NormalizeEmbeds(
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
|
||||||
Array.Empty<Embed>();
|
Array.Empty<Embed>()
|
||||||
|
);
|
||||||
|
|
||||||
var stickers =
|
var stickers =
|
||||||
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??
|
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??
|
||||||
@@ -73,6 +132,9 @@ public record Message(
|
|||||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() ??
|
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() ??
|
||||||
Array.Empty<User>();
|
Array.Empty<User>();
|
||||||
|
|
||||||
|
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
||||||
|
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||||
|
|
||||||
return new Message(
|
return new Message(
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -9,9 +9,20 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
|||||||
{
|
{
|
||||||
public static MessageReference Parse(JsonElement json)
|
public static MessageReference Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var messageId = json.GetPropertyOrNull("message_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
var messageId = json
|
||||||
var channelId = json.GetPropertyOrNull("channel_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
.GetPropertyOrNull("message_id")?
|
||||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
var channelId = json
|
||||||
|
.GetPropertyOrNull("channel_id")?
|
||||||
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
var guildId = json
|
||||||
|
.GetPropertyOrNull("guild_id")?
|
||||||
|
.GetNonWhiteSpaceStringOrNull()?
|
||||||
|
.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
return new MessageReference(messageId, channelId, guildId);
|
return new MessageReference(messageId, channelId, guildId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using JsonExtensions.Reading;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord.Data;
|
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)
|
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
|
||||||
{
|
{
|
||||||
private static string GetSourceUrl(Snowflake id, StickerFormat format)
|
private static string GetSourceUrl(Snowflake id, StickerFormat format)
|
||||||
@@ -15,9 +16,8 @@ public record Sticker(Snowflake Id, string Name, StickerFormat Format, string So
|
|||||||
public static Sticker Parse(JsonElement json)
|
public static Sticker Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
var name = json.GetProperty("name").GetNonNullString();
|
||||||
var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
|
var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
|
||||||
|
|
||||||
var sourceUrl = GetSourceUrl(id, format);
|
var sourceUrl = GetSourceUrl(id, format);
|
||||||
|
|
||||||
return new Sticker(id, name, format, sourceUrl);
|
return new Sticker(id, name, format, sourceUrl);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public partial record User
|
|||||||
? "gif"
|
? "gif"
|
||||||
: "png";
|
: "png";
|
||||||
|
|
||||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=128";
|
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=512";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static User Parse(JsonElement json)
|
public static User Parse(JsonElement json)
|
||||||
@@ -39,8 +39,8 @@ public partial record User
|
|||||||
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
|
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
|
||||||
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
||||||
var name = json.GetProperty("username").GetNonNullString();
|
var name = json.GetProperty("username").GetNonNullString();
|
||||||
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
|
|
||||||
|
|
||||||
|
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
||||||
? GetAvatarUrl(id, avatarHash)
|
? GetAvatarUrl(id, avatarHash)
|
||||||
: GetDefaultAvatarUrl(discriminator);
|
: GetDefaultAvatarUrl(discriminator);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -34,9 +33,12 @@ public class DiscordClient
|
|||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
|
|
||||||
request.Headers.Authorization = isBot
|
// Don't validate because token can have invalid characters
|
||||||
? new AuthenticationHeaderValue("Bot", _token)
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||||
: new AuthenticationHeaderValue(_token);
|
request.Headers.TryAddWithoutValidation(
|
||||||
|
"Authorization",
|
||||||
|
isBot ? $"Bot {_token}" : _token
|
||||||
|
);
|
||||||
|
|
||||||
return await Http.Client.SendAsync(
|
return await Http.Client.SendAsync(
|
||||||
request,
|
request,
|
||||||
@@ -49,7 +51,7 @@ public class DiscordClient
|
|||||||
string url,
|
string url,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
|
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||||
{
|
{
|
||||||
if (_tokenKind == TokenKind.User)
|
if (_tokenKind == TokenKind.User)
|
||||||
return await GetResponseAsync(url, false, innerCancellationToken);
|
return await GetResponseAsync(url, false, innerCancellationToken);
|
||||||
@@ -179,6 +181,8 @@ public class DiscordClient
|
|||||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
||||||
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Discord positions are not deterministic, so we need to normalize them
|
||||||
|
// because the user may refer to the channel position via file name template.
|
||||||
var position = 0;
|
var position = 0;
|
||||||
|
|
||||||
foreach (var channelJson in responseOrdered)
|
foreach (var channelJson in responseOrdered)
|
||||||
|
|||||||
@@ -49,7 +49,15 @@ public partial record struct Snowflake
|
|||||||
public static Snowflake Parse(string str) => Parse(str, null);
|
public static Snowflake Parse(string str) => Parse(str, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record struct Snowflake : IComparable<Snowflake>
|
public partial record struct Snowflake : IComparable<Snowflake>, IComparable
|
||||||
{
|
{
|
||||||
public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
|
public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
|
||||||
|
|
||||||
|
public int CompareTo(object? obj)
|
||||||
|
{
|
||||||
|
if (obj is not Snowflake other)
|
||||||
|
throw new ArgumentException($"Object must be of type {nameof(Snowflake)}.");
|
||||||
|
|
||||||
|
return Value.CompareTo(other.Value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,9 +7,10 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Gress" Version="2.0.1" />
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.0" />
|
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.1" />
|
||||||
<PackageReference Include="Polly" Version="7.2.3" />
|
<PackageReference Include="Polly" Version="7.2.3" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||||
|
<PackageReference Include="WebMarkupMin.Core" Version="2.9.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -60,8 +60,9 @@ public partial record ExportRequest
|
|||||||
"%C" => channel.Name,
|
"%C" => channel.Name,
|
||||||
"%p" => channel.Position?.ToString() ?? "0",
|
"%p" => channel.Position?.ToString() ?? "0",
|
||||||
"%P" => channel.Category.Position?.ToString() ?? "0",
|
"%P" => channel.Category.Position?.ToString() ?? "0",
|
||||||
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
|
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
|
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
|
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
|
||||||
"%%" => "%",
|
"%%" => "%",
|
||||||
_ => m.Value
|
_ => m.Value
|
||||||
})
|
})
|
||||||
@@ -93,7 +94,7 @@ public partial record ExportRequest
|
|||||||
// Date range
|
// Date range
|
||||||
if (after is not null || before is not null)
|
if (after is not null || before is not null)
|
||||||
{
|
{
|
||||||
buffer.Append(" (");
|
buffer.Append(' ').Append('(');
|
||||||
|
|
||||||
// Both 'after' and 'before' are set
|
// Both 'after' and 'before' are set
|
||||||
if (after is not null && before is not null)
|
if (after is not null && before is not null)
|
||||||
@@ -111,11 +112,11 @@ public partial record ExportRequest
|
|||||||
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.Append(")");
|
buffer.Append(')');
|
||||||
}
|
}
|
||||||
|
|
||||||
// File extension
|
// File extension
|
||||||
buffer.Append($".{format.GetFileExtension()}");
|
buffer.Append('.').Append(format.GetFileExtension());
|
||||||
|
|
||||||
return PathEx.EscapeFileName(buffer.ToString());
|
return PathEx.EscapeFileName(buffer.ToString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ internal class HasMessageFilter : MessageFilter
|
|||||||
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
||||||
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
||||||
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||||
|
MessageContentMatchKind.Pin => message.IsPinned,
|
||||||
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
|
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -7,5 +7,6 @@ internal enum MessageContentMatchKind
|
|||||||
File,
|
File,
|
||||||
Video,
|
Video,
|
||||||
Image,
|
Image,
|
||||||
Sound
|
Sound,
|
||||||
|
Pin
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,8 @@ internal static class FilterGrammar
|
|||||||
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||||
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||||
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||||
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound))
|
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
|
||||||
|
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
|
||||||
))
|
))
|
||||||
.Select(k => (MessageFilter) new HasMessageFilter(k))
|
.Select(k => (MessageFilter) new HasMessageFilter(k))
|
||||||
.Named("has:<value>");
|
.Named("has:<value>");
|
||||||
|
|||||||
@@ -35,45 +35,42 @@ internal partial class MediaDownloader
|
|||||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||||
|
|
||||||
// Reuse existing files if we're allowed to
|
// Reuse existing files if we're allowed to
|
||||||
if (_reuseMedia && File.Exists(filePath))
|
if (!_reuseMedia || !File.Exists(filePath))
|
||||||
return _pathCache[url] = filePath;
|
|
||||||
|
|
||||||
Directory.CreateDirectory(_workingDirPath);
|
|
||||||
|
|
||||||
// This retries on IOExceptions which is dangerous as we're also working with files
|
|
||||||
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
// Download the file
|
Directory.CreateDirectory(_workingDirPath);
|
||||||
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
|
await Http.ResiliencePolicy.ExecuteAsync(async () =>
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
// Download the file
|
||||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
using var response = await Http.Client.GetAsync(url, cancellationToken);
|
||||||
? date
|
await using (var output = File.Create(filePath))
|
||||||
: (DateTimeOffset?) null
|
await response.Content.CopyToAsync(output, cancellationToken);
|
||||||
);
|
|
||||||
|
|
||||||
if (lastModified is not null)
|
// Try to set the file date according to the last-modified header
|
||||||
|
try
|
||||||
{
|
{
|
||||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
? date
|
||||||
|
: (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
|
||||||
catch
|
{
|
||||||
{
|
// This can apparently fail for some reason.
|
||||||
// This can apparently fail for some reason.
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
// Updating file dates is not a critical task, so we'll just
|
||||||
// Updating file dates is not a critical task, so we'll just
|
// ignore exceptions thrown here.
|
||||||
// ignore exceptions thrown here.
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
return _pathCache[url] = filePath;
|
return _pathCache[url] = filePath;
|
||||||
}
|
}
|
||||||
@@ -103,7 +100,7 @@ internal partial class MediaDownloader
|
|||||||
// Otherwise, use the original file name but inject the hash in the middle
|
// Otherwise, use the original file name but inject the hash in the middle
|
||||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||||
var fileExtension = Path.GetExtension(fileName);
|
var fileExtension = Path.GetExtension(fileName);
|
||||||
|
|
||||||
// Probably not a file extension, just a dot in a long file name
|
// Probably not a file extension, just a dot in a long file name
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708
|
||||||
if (fileExtension.Length > 41)
|
if (fileExtension.Length > 41)
|
||||||
@@ -114,4 +111,4 @@ internal partial class MediaDownloader
|
|||||||
|
|
||||||
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
|
||||||
|
|
||||||
// Used for grouping contiguous messages in HTML export
|
|
||||||
internal partial class MessageGroup
|
|
||||||
{
|
|
||||||
public User Author { get; }
|
|
||||||
|
|
||||||
public DateTimeOffset Timestamp { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { get; }
|
|
||||||
|
|
||||||
public MessageReference? Reference { get; }
|
|
||||||
|
|
||||||
public Message? ReferencedMessage {get; }
|
|
||||||
|
|
||||||
public MessageGroup(
|
|
||||||
User author,
|
|
||||||
DateTimeOffset timestamp,
|
|
||||||
MessageReference? reference,
|
|
||||||
Message? referencedMessage,
|
|
||||||
IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
Author = author;
|
|
||||||
Timestamp = timestamp;
|
|
||||||
Reference = reference;
|
|
||||||
ReferencedMessage = referencedMessage;
|
|
||||||
Messages = messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal partial class MessageGroup
|
|
||||||
{
|
|
||||||
public static bool CanJoin(Message message1, Message message2) =>
|
|
||||||
// Must be from the same author
|
|
||||||
message1.Author.Id == message2.Author.Id &&
|
|
||||||
// Author's name must not have changed between messages
|
|
||||||
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
|
||||||
// Duration between messages must be 7 minutes or less
|
|
||||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
|
|
||||||
// Other message must not be a reply
|
|
||||||
message2.Reference is null;
|
|
||||||
|
|
||||||
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
var first = messages.First();
|
|
||||||
|
|
||||||
return new MessageGroup(
|
|
||||||
first.Author,
|
|
||||||
first.Timestamp,
|
|
||||||
first.Reference,
|
|
||||||
first.ReferencedMessage,
|
|
||||||
messages
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,134 +2,165 @@
|
|||||||
@using System.Linq
|
@using System.Linq
|
||||||
@using System.Threading.Tasks
|
@using System.Threading.Tasks
|
||||||
@using DiscordChatExporter.Core.Discord.Data
|
@using DiscordChatExporter.Core.Discord.Data
|
||||||
|
@using DiscordChatExporter.Core.Discord.Data.Embeds
|
||||||
@using DiscordChatExporter.Core.Exporting.Writers.Html;
|
@using DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
@using DiscordChatExporter.Core.Utils.Extensions
|
||||||
|
|
||||||
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||||
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
|
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
|
||||||
|
|
||||||
@{
|
@{
|
||||||
|
var firstMessage = Model.Messages.First();
|
||||||
|
|
||||||
|
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||||
|
|
||||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||||
|
|
||||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||||
|
|
||||||
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
||||||
|
|
||||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
|
||||||
|
|
||||||
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
|
||||||
|
|
||||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
var userNick = firstMessage.Author.IsBot
|
||||||
|
? firstMessage.Author.Name
|
||||||
|
: userMember?.Nick ?? firstMessage.Author.Name;
|
||||||
|
|
||||||
var userColorStyle = userColor is not null
|
var referencedUserMember = firstMessage.ReferencedMessage is not null
|
||||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
? Model.ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var userNick = Model.MessageGroup.Author.IsBot
|
var referencedUserColor = firstMessage.ReferencedMessage is not null
|
||||||
? Model.MessageGroup.Author.Name
|
? Model.ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id)
|
||||||
: userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
|
||||||
|
|
||||||
var referencedUserMember = Model.MessageGroup.ReferencedMessage is not null
|
|
||||||
? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var referencedUserColor = Model.MessageGroup.ReferencedMessage is not null
|
var referencedUserNick = firstMessage.ReferencedMessage is not null
|
||||||
? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id)
|
? firstMessage.ReferencedMessage.Author.IsBot
|
||||||
: null;
|
? firstMessage.ReferencedMessage.Author.Name
|
||||||
|
: referencedUserMember?.Nick ?? firstMessage.ReferencedMessage.Author.Name
|
||||||
var referencedUserColorStyle = referencedUserColor is not null
|
|
||||||
? $"color: rgb({referencedUserColor?.R},{referencedUserColor?.G},{referencedUserColor?.B})"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var referencedUserNick = Model.MessageGroup.ReferencedMessage is not null
|
|
||||||
? Model.MessageGroup.ReferencedMessage.Author.IsBot
|
|
||||||
? Model.MessageGroup.ReferencedMessage.Author.Name
|
|
||||||
: referencedUserMember?.Nick ?? Model.MessageGroup.ReferencedMessage.Author.Name
|
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
@{/* Referenced message */}
|
@foreach (var (message, i) in Model.Messages.WithIndex())
|
||||||
@if (Model.MessageGroup.Reference is not null)
|
{
|
||||||
{
|
var isFirst = i == 0;
|
||||||
<div class="chatlog__reference-symbol"></div>
|
|
||||||
<div class="chatlog__reference">
|
|
||||||
@if (Model.MessageGroup.ReferencedMessage is not null)
|
|
||||||
{
|
|
||||||
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
|
||||||
<span class="chatlog__reference-name" title="@Model.MessageGroup.ReferencedMessage.Author.FullName" style="@referencedUserColorStyle">@referencedUserNick</span>
|
|
||||||
<div class="chatlog__reference-content">
|
|
||||||
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content))
|
|
||||||
{
|
|
||||||
@Raw(FormatEmbedMarkdown(Model.MessageGroup.ReferencedMessage.Content))
|
|
||||||
}
|
|
||||||
else if (Model.MessageGroup.ReferencedMessage.Attachments.Any() || Model.MessageGroup.ReferencedMessage.Embeds.Any())
|
|
||||||
{
|
|
||||||
<em>Click to see attachment</em> <span>🖼️</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<em>Click to see original message</em>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp is not null)
|
// Hide message content if it only contains a link to an image which is embedded, and nothing else
|
||||||
{
|
var isContentHidden =
|
||||||
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(Model.MessageGroup.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
message.Embeds.Count == 1 &&
|
||||||
}
|
message.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.Embeds.Single())?.Url;
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="chatlog__reference-unknown">
|
|
||||||
Original message was deleted or could not be loaded.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Avatar */}
|
var isReferencedContentHidden =
|
||||||
<div class="chatlog__author-avatar-container">
|
message.ReferencedMessage is not null &&
|
||||||
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
message.ReferencedMessage.Embeds.Count == 1 &&
|
||||||
</div>
|
message.ReferencedMessage.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.ReferencedMessage.Embeds.Single())?.Url;
|
||||||
|
|
||||||
<div class="chatlog__messages">
|
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
|
||||||
@{/* Author name */}
|
<div class="chatlog__message">
|
||||||
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
|
@{/* Left side */}
|
||||||
|
<div class="chatlog__message-aside">
|
||||||
@{/* Bot tag */}
|
@if (isFirst)
|
||||||
@if (Model.MessageGroup.Author.IsBot)
|
|
||||||
{
|
|
||||||
<span class="chatlog__bot-tag">BOT</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Message timestamp */}
|
|
||||||
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
|
|
||||||
|
|
||||||
@{/* Messages in a group */}
|
|
||||||
@foreach (var message in Model.MessageGroup.Messages)
|
|
||||||
{
|
|
||||||
<div class="chatlog__message @(message.IsPinned ? "chatlog__message--pinned" : null)" data-message-id="@message.Id" id="message-@message.Id" title="Message sent: @FormatDate(message.Timestamp)">
|
|
||||||
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
|
|
||||||
{
|
{
|
||||||
<div class="chatlog__content">
|
// Reference symbol
|
||||||
<div class="markdown">
|
if (message.Reference is not null)
|
||||||
@{/* Message content */}
|
{
|
||||||
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
|
<div class="chatlog__reference-symbol"></div>
|
||||||
|
}
|
||||||
|
|
||||||
@{/* Edit timestamp */}
|
// Avatar
|
||||||
@if (message.EditedTimestamp is not null)
|
<img class="chatlog__avatar" src="@await ResolveUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@{/* Right side */}
|
||||||
|
<div class="chatlog__message-primary">
|
||||||
|
@if (isFirst)
|
||||||
|
{
|
||||||
|
// Reference
|
||||||
|
if (message.Reference is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__reference">
|
||||||
|
@if (message.ReferencedMessage is not null)
|
||||||
{
|
{
|
||||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||||
|
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
|
||||||
|
<div class="chatlog__reference-content">
|
||||||
|
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !isReferencedContentHidden)
|
||||||
|
{
|
||||||
|
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore-->
|
||||||
|
}
|
||||||
|
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
|
||||||
|
{
|
||||||
|
<em>Click to see attachment</em>
|
||||||
|
<span>🖼️</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<em>Click to see original message</em>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (message.ReferencedMessage.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__reference-unknown">
|
||||||
|
Original message was deleted or could not be loaded.
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
<div class="chatlog__header">
|
||||||
|
@{/* Author name */}
|
||||||
|
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||||
|
|
||||||
|
@{/* Bot label */}
|
||||||
|
@if (message.Author.IsBot)
|
||||||
|
{
|
||||||
|
<span class="chatlog__bot-label">BOT</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Timestamp */}
|
||||||
|
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Content */}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__content chatlog__markdown">
|
||||||
|
@{/* Text */}
|
||||||
|
@if (!isContentHidden)
|
||||||
|
{
|
||||||
|
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Edited timestamp */}
|
||||||
|
@if (message.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@{/* Attachments */}
|
@{/* Attachments */}
|
||||||
@foreach (var attachment in message.Attachments)
|
@foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")">
|
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
|
||||||
@{/* Spoiler caption */}
|
@{/* Spoiler caption */}
|
||||||
@if (attachment.IsSpoiler)
|
@if (attachment.IsSpoiler)
|
||||||
{
|
{
|
||||||
@@ -140,26 +171,26 @@
|
|||||||
@if (attachment.IsImage)
|
@if (attachment.IsImage)
|
||||||
{
|
{
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")" loading="lazy">
|
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else if (attachment.IsVideo)
|
else if (attachment.IsVideo)
|
||||||
{
|
{
|
||||||
<video class="chatlog__attachment-media" controls>
|
<video class="chatlog__attachment-media" controls>
|
||||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
|
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||||
</video>
|
</video>
|
||||||
}
|
}
|
||||||
else if (attachment.IsAudio)
|
else if (attachment.IsAudio)
|
||||||
{
|
{
|
||||||
<audio class="chatlog__attachment-media" controls>
|
<audio class="chatlog__attachment-media" controls>
|
||||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
|
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||||
</audio>
|
</audio>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="chatlog__attachment-generic">
|
<div class="chatlog__attachment-generic">
|
||||||
<svg class="chatlog__attachment-generic-icon">
|
<svg class="chatlog__attachment-generic-icon">
|
||||||
<use href="#icon-attachment" />
|
<use href="#attachment-icon"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="chatlog__attachment-generic-name">
|
<div class="chatlog__attachment-generic-name">
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
@@ -202,7 +233,7 @@
|
|||||||
@{/* Color pill */}
|
@{/* Color pill */}
|
||||||
@if (embed.Color is not null)
|
@if (embed.Color is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -215,7 +246,7 @@
|
|||||||
@{/* Embed author */}
|
@{/* Embed author */}
|
||||||
@if (embed.Author is not null)
|
@if (embed.Author is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-author">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||||
@@ -223,16 +254,16 @@
|
|||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
{
|
{
|
||||||
<span class="chatlog__embed-author-name">
|
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
{
|
||||||
{
|
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
}
|
</a>
|
||||||
else
|
}
|
||||||
{
|
else
|
||||||
@embed.Author.Name
|
{
|
||||||
}
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
</span>
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -244,12 +275,12 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -270,7 +301,7 @@
|
|||||||
@{/* Color pill */}
|
@{/* Color pill */}
|
||||||
@if (embed.Color is not null)
|
@if (embed.Color is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -283,7 +314,7 @@
|
|||||||
@{/* Embed author */}
|
@{/* Embed author */}
|
||||||
@if (embed.Author is not null)
|
@if (embed.Author is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-author">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||||
@@ -291,16 +322,16 @@
|
|||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
{
|
{
|
||||||
<span class="chatlog__embed-author-name">
|
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
{
|
||||||
{
|
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
}
|
</a>
|
||||||
else
|
}
|
||||||
{
|
else
|
||||||
@embed.Author.Name
|
{
|
||||||
}
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
</span>
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -312,12 +343,12 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -326,7 +357,7 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-description">
|
<div class="chatlog__embed-description">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Description))<!--/wmm:ignore--></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,14 +371,14 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-name">
|
<div class="chatlog__embed-field-name">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Name))<!--/wmm:ignore--></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-value">
|
<div class="chatlog__embed-field-value">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Value))<!--/wmm:ignore--></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -367,13 +398,21 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@{/* Embed image */}
|
@{/* Embed images */}
|
||||||
@if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url))
|
@if (embed.Images.Any())
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-image-container">
|
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
|
||||||
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)">
|
@foreach (var image in embed.Images)
|
||||||
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image" loading="lazy">
|
{
|
||||||
</a>
|
if (!string.IsNullOrWhiteSpace(image.Url))
|
||||||
|
{
|
||||||
|
<div class="chatlog__embed-image-container">
|
||||||
|
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||||
|
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,11 +457,11 @@
|
|||||||
<div class="chatlog__sticker" title="@sticker.Name">
|
<div class="chatlog__sticker" title="@sticker.Name">
|
||||||
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
|
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
|
||||||
{
|
{
|
||||||
<img class="chatlog__sticker--media" src="@(await ResolveUrlAsync(sticker.SourceUrl))" alt="Sticker">
|
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||||
}
|
}
|
||||||
else if (sticker.Format == StickerFormat.Lottie)
|
else if (sticker.Format == StickerFormat.Lottie)
|
||||||
{
|
{
|
||||||
<div class="chatlog__sticker--media" data-source="@(await ResolveUrlAsync(sticker.SourceUrl))"></div>
|
<div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -434,13 +473,14 @@
|
|||||||
@foreach (var reaction in message.Reactions)
|
@foreach (var reaction in message.Reactions)
|
||||||
{
|
{
|
||||||
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
||||||
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
<img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
||||||
<span class="chatlog__reaction-count">@reaction.Count</span>
|
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
@@ -6,12 +8,12 @@ internal class MessageGroupTemplateContext
|
|||||||
{
|
{
|
||||||
public ExportContext ExportContext { get; }
|
public ExportContext ExportContext { get; }
|
||||||
|
|
||||||
public MessageGroup MessageGroup { get; }
|
public IReadOnlyList<Message> Messages { get; }
|
||||||
|
|
||||||
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
|
public MessageGroupTemplateContext(ExportContext exportContext, IReadOnlyList<Message> messages)
|
||||||
{
|
{
|
||||||
ExportContext = exportContext;
|
ExportContext = exportContext;
|
||||||
MessageGroup = messageGroup;
|
Messages = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
@inherits MiniRazor.TemplateBase<PostambleTemplateContext>
|
@inherits MiniRazor.TemplateBase<PostambleTemplateContext>
|
||||||
|
|
||||||
@{/* Close elements opened by preamble */}
|
@{/* Close elements opened by preamble */}
|
||||||
|
<!--wmm:ignore-->
|
||||||
</div>
|
</div>
|
||||||
|
<!--/wmm:ignore-->
|
||||||
|
|
||||||
<div class="postamble">
|
<div class="postamble">
|
||||||
<div class="postamble__entry">Exported @Model.MessagesWritten.ToString("N0") message(s)</div>
|
<div class="postamble__entry">Exported @Model.MessagesWritten.ToString("N0") message(s)</div>
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
? darkVariant
|
? darkVariant
|
||||||
: lightVariant;
|
: lightVariant;
|
||||||
|
|
||||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
string GetFontUrl(int weight) =>
|
||||||
|
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff";
|
||||||
|
|
||||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken);
|
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken);
|
||||||
|
|
||||||
|
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||||
|
|
||||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,39 +32,41 @@
|
|||||||
@{/* Styling */}
|
@{/* Styling */}
|
||||||
<style>
|
<style>
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(300)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
|
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(400)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(500)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(600)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(700)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
background-color: @Themed("#36393e", "#ffffff");
|
background-color: @Themed("#36393e", "#ffffff");
|
||||||
color: @Themed("#dcddde", "#23262a");
|
color: @Themed("#dcddde", "#23262a");
|
||||||
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: @Themed("400", "500");
|
font-weight: @Themed("400", "500");
|
||||||
}
|
}
|
||||||
@@ -77,114 +82,15 @@
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
image-rendering: high-quality;
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
.markdown {
|
|
||||||
max-width: 100%;
|
|
||||||
line-height: 1.3;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preserve-whitespace {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text {
|
|
||||||
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text--hidden {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: @Themed("#202225", "#b9bbbe");
|
|
||||||
color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text--hidden:hover {
|
|
||||||
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text--hidden::selection {
|
|
||||||
color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote {
|
|
||||||
display: flex;
|
|
||||||
margin: 0.05em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote-border {
|
|
||||||
margin-right: 0.5em;
|
|
||||||
border: 2px solid @Themed("#4f545c", "#c7ccd1");
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pre {
|
|
||||||
background-color: @Themed("#2f3136", "#f9f9f9");
|
|
||||||
font-family: "Consolas", "Courier New", Courier, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pre--multiline {
|
|
||||||
margin-top: 0.25em;
|
|
||||||
padding: 0.5em;
|
|
||||||
border: 2px solid @Themed("#282b30", "#f3f3f3");
|
|
||||||
border-radius: 5px;
|
|
||||||
color: @Themed("#b9bbbe", "#657b83");
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Override Highlight.js styles with a higher specificity selector */}
|
|
||||||
.pre--multiline.hljs {
|
|
||||||
background-color: @Themed("#2f3136", "#f9f9f9");
|
|
||||||
color: @Themed("#b9bbbe", "#657b83");
|
|
||||||
}
|
|
||||||
|
|
||||||
.pre--inline {
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention {
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 2px;
|
|
||||||
color: @Themed("#dee0fc", "#505cdc");
|
|
||||||
background-color: @Themed("rgba(88, 101, 242, .3)", "rgba(88, 101, 242, .15)");
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention:hover {
|
|
||||||
background-color: #5865f2;
|
|
||||||
color: #ffffff
|
|
||||||
}
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 2px;
|
|
||||||
background-color: @Themed("rgba(255, 255, 255, 0.06)", "rgba(6, 6, 7, 0.08)");
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji {
|
|
||||||
width: 1.325em;
|
|
||||||
height: 1.325em;
|
|
||||||
margin: 0 0.06em;
|
|
||||||
vertical-align: -0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji--small {
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji--large {
|
|
||||||
width: 2.8em;
|
|
||||||
height: 2.8em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble {
|
.preamble {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin: 0 0.3em 0.6em 0.3em;
|
|
||||||
max-width: 100%;
|
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble__guild-icon-container {
|
.preamble__guild-icon-container {
|
||||||
@@ -198,59 +104,114 @@
|
|||||||
|
|
||||||
.preamble__entries-container {
|
.preamble__entries-container {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
margin-left: 0.6em;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble__entry {
|
.preamble__entry {
|
||||||
font-size: 1.4em;
|
margin-bottom: 0.15rem;
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: @Themed("#ffffff", "#2f3136");
|
||||||
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble__entry--small {
|
.preamble__entry--small {
|
||||||
font-size: 1em;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog {
|
.chatlog {
|
||||||
max-width: 100%;
|
padding: 1rem 0;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
||||||
|
border-bottom: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__message-group {
|
.chatlog__message-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-container {
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-container--highlighted {
|
||||||
|
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-container--pinned {
|
||||||
|
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin: 0 0.6em;
|
|
||||||
padding: 0.9em 0;
|
|
||||||
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: bidi-override;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message:hover {
|
||||||
|
background-color: @Themed("#32353b", "#fafafa");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message:hover .chatlog__short-timestamp {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-aside {
|
||||||
|
grid-column: 1;
|
||||||
|
width: 72px;
|
||||||
|
padding: 0.15rem 0.15rem 0 0.15rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-symbol {
|
.chatlog__reference-symbol {
|
||||||
grid-column: 1;
|
height: 10px;
|
||||||
margin: 8px 4px 4px 18px;
|
margin: 6px 4px 4px 36px;
|
||||||
border-left: 2px solid @Themed("#4f545c", "#c7ccd1");
|
border-left: 2px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
border-top: 2px solid @Themed("#4f545c", "#c7ccd1");
|
border-top: 2px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
border-radius: 8px 0 0 0;
|
border-radius: 8px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__short-timestamp {
|
||||||
|
display: none;
|
||||||
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: bidi-override;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-primary {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__reference {
|
.chatlog__reference {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-column: 2;
|
margin-bottom: 0.15rem;
|
||||||
margin-bottom: 0.25em;
|
align-items: center;
|
||||||
font-size: 0.875em;
|
color: @Themed("#b5b6b8", "#5f5f60");
|
||||||
|
font-size: 0.875rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
align-items: center;
|
|
||||||
color: @Themed("#b5b6b8", "#5f5f60");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-avatar {
|
.chatlog__reference-avatar {
|
||||||
border-radius: 50%;
|
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-right: 0.25em;
|
margin-right: 0.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-name {
|
.chatlog__reference-author {
|
||||||
margin-right: 0.3em;
|
margin-right: 0.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,13 +220,8 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-content a:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__reference-link {
|
.chatlog__reference-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: @Themed("#b5b6b8", "#5f5f60");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-link * {
|
.chatlog__reference-link * {
|
||||||
@@ -273,82 +229,83 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__reference-link .chatlog__markdown-quote {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__reference-link .chatlog__markdown-pre {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__reference-link:hover {
|
.chatlog__reference-link:hover {
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: @Themed("#ffffff", "#2f3136");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-link:hover *:not(.spoiler-text) {
|
.chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-edited-timestamp {
|
.chatlog__reference-edited-timestamp {
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25rem;
|
||||||
font-size: 0.8em;
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
unicode-bidi: bidi-override;
|
font-size: 0.75rem;
|
||||||
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__author-avatar-container {
|
|
||||||
grid-column: 1;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__author-avatar {
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__messages {
|
|
||||||
grid-column: 2;
|
|
||||||
min-width: 50%;
|
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
|
unicode-bidi: bidi-override;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__author-name {
|
.chatlog__header {
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__author {
|
||||||
font-weight: @Themed("500", "600");
|
font-weight: @Themed("500", "600");
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: @Themed("#ffffff", "#2f3136");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__bot-label {
|
||||||
|
position: relative;
|
||||||
|
top: -0.1rem;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #5865F2;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__timestamp {
|
.chatlog__timestamp {
|
||||||
margin-left: 0.3em;
|
margin-left: 0.3rem;
|
||||||
font-size: 0.75em;
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
unicode-bidi: bidi-override;
|
unicode-bidi: bidi-override;
|
||||||
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__message {
|
.chatlog__timestamp a {
|
||||||
padding: 0.1em 0.3em;
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
margin: 0 -0.3em;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__message--highlighted {
|
|
||||||
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__message--pinned {
|
|
||||||
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__content {
|
.chatlog__content {
|
||||||
font-size: 0.95em;
|
padding-right: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__edited-timestamp {
|
.chatlog__edited-timestamp {
|
||||||
margin-left: 0.15em;
|
margin-left: 0.15rem;
|
||||||
font-size: 0.8em;
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment {
|
.chatlog__attachment {
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 0.3em;
|
width: fit-content;
|
||||||
|
margin-top: 0.3rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -364,18 +321,18 @@
|
|||||||
|
|
||||||
.chatlog__attachment-spoiler-caption {
|
.chatlog__attachment-spoiler-caption {
|
||||||
display: none;
|
display: none;
|
||||||
z-index: 999;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
z-index: 999;
|
||||||
padding: 0.4em 0.8em;
|
padding: 0.4rem 0.8rem;
|
||||||
background-color: rgba(0, 0, 0, 0.9);
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
color: #dcddde;
|
color: #dcddde;
|
||||||
font-size: 0.9em;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment--hidden .chatlog__attachment-spoiler-caption {
|
.chatlog__attachment--hidden .chatlog__attachment-spoiler-caption {
|
||||||
@@ -387,9 +344,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment-media {
|
.chatlog__attachment-media {
|
||||||
vertical-align: top;
|
|
||||||
max-width: 45vw;
|
max-width: 45vw;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
|
vertical-align: top;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,13 +355,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment-generic {
|
.chatlog__attachment-generic {
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: @Themed("#2f3136", "#f2f3f5");
|
|
||||||
border: 1px solid @Themed("#292b2f", "#ebedef");
|
border: 1px solid @Themed("#292b2f", "#ebedef");
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
background-color: @Themed("#2f3136", "#f2f3f5");
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,19 +387,15 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__edited-timestamp {
|
|
||||||
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__embed {
|
.chatlog__embed {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 0.3em;
|
margin-top: 0.3rem;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-color-pill {
|
.chatlog__embed-color-pill {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 0.25em;
|
width: 0.25rem;
|
||||||
border-top-left-radius: 3px;
|
border-top-left-radius: 3px;
|
||||||
border-bottom-left-radius: 3px;
|
border-bottom-left-radius: 3px;
|
||||||
}
|
}
|
||||||
@@ -454,11 +407,11 @@
|
|||||||
.chatlog__embed-content-container {
|
.chatlog__embed-content-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5em 0.6em;
|
padding: 0.5rem 0.6rem;
|
||||||
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
|
||||||
border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)");
|
border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)");
|
||||||
border-top-right-radius: 3px;
|
border-top-right-radius: 3px;
|
||||||
border-bottom-right-radius: 3px;
|
border-bottom-right-radius: 3px;
|
||||||
|
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-content {
|
.chatlog__embed-content {
|
||||||
@@ -470,116 +423,127 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author {
|
.chatlog__embed-author-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 0.3em;
|
margin-bottom: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-icon {
|
.chatlog__embed-author-icon {
|
||||||
margin-right: 0.5em;
|
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-name {
|
.chatlog__embed-author {
|
||||||
font-size: 0.875em;
|
color: @Themed("#ffffff", "#4f545c");
|
||||||
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
unicode-bidi: bidi-override;
|
unicode-bidi: bidi-override;
|
||||||
color: @Themed("#ffffff", "#4f545c")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-name-link {
|
.chatlog__embed-author-link {
|
||||||
color: @Themed("#ffffff", "#4f545c");
|
color: @Themed("#ffffff", "#4f545c");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-title {
|
.chatlog__embed-title {
|
||||||
margin-bottom: 0.2em;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 0.875em;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @Themed("#ffffff", "#4f545c");
|
color: @Themed("#ffffff", "#4f545c");
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-description {
|
.chatlog__embed-description {
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-fields {
|
.chatlog__embed-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0 0.5em;
|
gap: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field {
|
.chatlog__embed-field {
|
||||||
flex: 0;
|
flex: 0;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
max-width: 506px;
|
max-width: 506px;
|
||||||
padding-top: 0.6em;
|
padding-top: 0.6rem;
|
||||||
font-size: 0.875em;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field--inline {
|
.chatlog__embed-field--inline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
min-width: 150px;
|
min-width: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field-name {
|
.chatlog__embed-field-name {
|
||||||
margin-bottom: 0.2em;
|
margin-bottom: 0.2rem;
|
||||||
font-weight: 600;
|
|
||||||
color: @Themed("#ffffff", "#36393e");
|
color: @Themed("#ffffff", "#36393e");
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field-value {
|
.chatlog__embed-field-value {
|
||||||
font-weight: 500;
|
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-thumbnail {
|
.chatlog__embed-thumbnail {
|
||||||
flex: 0;
|
flex: 0;
|
||||||
margin-left: 1.2em;
|
|
||||||
max-width: 80px;
|
max-width: 80px;
|
||||||
max-height: 80px;
|
max-height: 80px;
|
||||||
|
margin-left: 1.2rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-image-container {
|
.chatlog__embed-images {
|
||||||
margin-top: 0.6em;
|
display: grid;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__embed-images--single {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-image {
|
.chatlog__embed-image {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer {
|
.chatlog__embed-footer {
|
||||||
margin-top: 0.6em;
|
margin-top: 0.6rem;
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer-icon {
|
.chatlog__embed-footer-icon {
|
||||||
margin-right: 0.2em;
|
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
margin-right: 0.2rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer-text {
|
.chatlog__embed-footer-text {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
font-size: 0.75em;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-plainimage {
|
.chatlog__embed-plainimage {
|
||||||
vertical-align: top;
|
|
||||||
max-width: 45vw;
|
max-width: 45vw;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
|
vertical-align: top;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +552,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-youtube-container {
|
.chatlog__embed-youtube-container {
|
||||||
margin-top: 0.6em;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-youtube {
|
.chatlog__embed-youtube {
|
||||||
@@ -612,51 +576,134 @@
|
|||||||
|
|
||||||
.chatlog__reaction {
|
.chatlog__reaction {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
margin: 0.35rem 0.1rem 0.1rem 0;
|
||||||
margin: 0.35em 0.1em 0.1em 0;
|
padding: 0.125rem 0.375rem;
|
||||||
padding: .125rem .375rem;
|
border: 1px solid transparent;
|
||||||
background-color: @Themed("#2f3136", "#f2f3f5");
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border-color: transparent;
|
background-color: @Themed("#2f3136", "#f2f3f5");
|
||||||
border-width: 1px;
|
align-items: center;
|
||||||
border-style: solid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction:hover {
|
.chatlog__reaction:hover {
|
||||||
border-color: @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)");
|
border: 1px solid @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)");
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
background-color: @Themed("transparent", "white");
|
background-color: @Themed("transparent", "white");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction-count {
|
.chatlog__reaction-count {
|
||||||
min-width: 9px;
|
min-width: 9px;
|
||||||
margin-left: 0.35em;
|
margin-left: 0.35rem;
|
||||||
font-size: 0.875em;
|
|
||||||
color: @Themed("#b9bbbe", "#4f5660");
|
color: @Themed("#b9bbbe", "#4f5660");
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction:hover .chatlog__reaction-count {
|
.chatlog__reaction:hover .chatlog__reaction-count {
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__bot-tag {
|
.chatlog__markdown {
|
||||||
position: relative;
|
max-width: 100%;
|
||||||
top: -.1em;
|
|
||||||
margin-left: 0.3em;
|
|
||||||
padding: 0.05em 0.3em;
|
|
||||||
border-radius: 3px;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
background-color: #5865F2;
|
overflow-wrap: break-word;
|
||||||
color: #ffffff;
|
}
|
||||||
font-size: 0.625em;
|
|
||||||
|
.chatlog__markdown-preserve {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler {
|
||||||
|
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler--hidden {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: @Themed("#202225", "#b9bbbe");
|
||||||
|
color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler--hidden:hover {
|
||||||
|
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler--hidden::selection {
|
||||||
|
color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-quote {
|
||||||
|
display: flex;
|
||||||
|
margin: 0.05rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-quote-border {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border: 2px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre {
|
||||||
|
background-color: @Themed("#2f3136", "#f9f9f9");
|
||||||
|
font-family: "Consolas", "Courier New", Courier, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre--multiline {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid @Themed("#282b30", "#f3f3f3");
|
||||||
|
border-radius: 5px;
|
||||||
|
color: @Themed("#b9bbbe", "#657b83");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre--multiline.hljs {
|
||||||
|
background-color: @Themed("#2f3136", "#f9f9f9");
|
||||||
|
color: @Themed("#b9bbbe", "#657b83");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre--inline {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-mention {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
background-color: @Themed("rgba(88, 101, 242, .3)", "rgba(88, 101, 242, .15)");
|
||||||
|
color: @Themed("#dee0fc", "#505cdc");
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-mention:hover {
|
||||||
|
background-color: #5865f2;
|
||||||
|
color: #ffffff
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-timestamp {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__emoji {
|
||||||
|
width: 1.325rem;
|
||||||
|
height: 1.325rem;
|
||||||
|
margin: 0 0.06rem;
|
||||||
|
vertical-align: -0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__emoji--small {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__emoji--large {
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.postamble {
|
.postamble {
|
||||||
margin: 1.4em 0.3em 0.6em 0.3em;
|
padding: 1.25rem;
|
||||||
padding: 1em;
|
|
||||||
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.postamble__entry {
|
.postamble__entry {
|
||||||
@@ -665,16 +712,16 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
@{/* Syntax highlighting */}
|
@{/* Syntax highlighting */}
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
|
<link rel="stylesheet" href="@await ResolveUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{Model.ThemeName.ToLowerInvariant()}.min.css")">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
|
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('.pre--multiline').forEach(e => hljs.highlightBlock(e));
|
document.querySelectorAll('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@{/* Lottie animation support */}
|
@{/* Lottie animation support */}
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js"></script>
|
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
|
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
|
||||||
@@ -698,43 +745,46 @@
|
|||||||
@{/* Scripts */}
|
@{/* Scripts */}
|
||||||
<script>
|
<script>
|
||||||
function scrollToMessage(event, id) {
|
function scrollToMessage(event, id) {
|
||||||
var element = document.getElementById('message-' + id);
|
var element = document.getElementById('chatlog__message-container-' + id);
|
||||||
|
if (!element)
|
||||||
|
return;
|
||||||
|
|
||||||
if (element) {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
element.classList.add('chatlog__message-container--highlighted');
|
||||||
|
|
||||||
element.classList.add('chatlog__message--highlighted');
|
window.scrollTo({
|
||||||
|
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
|
||||||
window.scrollTo({
|
window.setTimeout(function() {
|
||||||
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
element.classList.remove('chatlog__message-container--highlighted');
|
||||||
behavior: 'smooth'
|
}, 2000);
|
||||||
});
|
|
||||||
|
|
||||||
window.setTimeout(function() {
|
|
||||||
element.classList.remove('chatlog__message--highlighted');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSpoiler(event, element) {
|
function showSpoiler(event, element) {
|
||||||
if (element && element.classList.contains('spoiler-text--hidden')) {
|
if (!element)
|
||||||
event.preventDefault();
|
return;
|
||||||
element.classList.remove('spoiler-text--hidden');
|
|
||||||
}
|
if (element.classList.contains('chatlog__attachment--hidden')) {
|
||||||
if (element && element.classList.contains('chatlog__attachment--hidden')) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
element.classList.remove('chatlog__attachment--hidden');
|
element.classList.remove('chatlog__attachment--hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (element.classList.contains('chatlog__markdown-spoiler--hidden')) {
|
||||||
|
event.preventDefault();
|
||||||
|
element.classList.remove('chatlog__markdown-spoiler--hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@{/* Icons */}
|
@{/* Icons */}
|
||||||
<svg style="display: none">
|
<svg style="display: none">
|
||||||
<symbol id="icon-attachment" viewBox="0 0 720 960">
|
<symbol id="attachment-icon" viewBox="0 0 720 960">
|
||||||
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" />
|
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z"/>
|
||||||
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" />
|
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/>
|
||||||
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" />
|
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/>
|
||||||
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" />
|
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
</head>
|
</head>
|
||||||
@@ -756,22 +806,24 @@
|
|||||||
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
|
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
|
||||||
{
|
{
|
||||||
<div class="preamble__entry preamble__entry--small">
|
<div class="preamble__entry preamble__entry--small">
|
||||||
@if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null)
|
@if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null)
|
||||||
{
|
{
|
||||||
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||||
}
|
}
|
||||||
else if (Model.ExportContext.Request.After is not null)
|
else if (Model.ExportContext.Request.After is not null)
|
||||||
{
|
{
|
||||||
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
|
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
|
||||||
}
|
}
|
||||||
else if (Model.ExportContext.Request.Before is not null)
|
else if (Model.ExportContext.Request.Before is not null)
|
||||||
{
|
{
|
||||||
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@{/* Preamble cuts off at this point */}
|
@{/* Preamble cuts off at this point */}
|
||||||
<div class="chatlog">
|
<!--wmm:ignore-->
|
||||||
|
<div class="chatlog">
|
||||||
|
<!--/wmm:ignore-->
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Writers.Html;
|
using DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
using WebMarkupMin.Core;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers;
|
namespace DiscordChatExporter.Core.Exporting.Writers;
|
||||||
|
|
||||||
@@ -13,7 +15,8 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
private readonly TextWriter _writer;
|
private readonly TextWriter _writer;
|
||||||
private readonly string _themeName;
|
private readonly string _themeName;
|
||||||
|
|
||||||
private readonly List<Message> _messageGroupBuffer = new();
|
private readonly HtmlMinifier _minifier = new();
|
||||||
|
private readonly List<Message> _messageGroup = new();
|
||||||
|
|
||||||
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
||||||
: base(stream, context)
|
: base(stream, context)
|
||||||
@@ -22,6 +25,23 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
_themeName = themeName;
|
_themeName = themeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanJoinGroup(Message message) =>
|
||||||
|
// First message in the group can always join
|
||||||
|
_messageGroup.LastOrDefault() is not { } lastMessage ||
|
||||||
|
// Must be from the same author
|
||||||
|
lastMessage.Author.Id == message.Author.Id &&
|
||||||
|
// Author's name must not have changed between messages
|
||||||
|
string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) &&
|
||||||
|
// Duration between messages must be 7 minutes or less
|
||||||
|
(message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 &&
|
||||||
|
// Other message must not be a reply
|
||||||
|
message.Reference is null;
|
||||||
|
|
||||||
|
// Use <!--wmm:ignore--> to preserve blocks of code inside the templates
|
||||||
|
private string Minify(string html) => _minifier
|
||||||
|
.Minify(html, false)
|
||||||
|
.MinifiedContent;
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
||||||
@@ -29,20 +49,24 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
// We are not writing directly to output because Razor
|
// We are not writing directly to output because Razor
|
||||||
// does not actually do asynchronous writes to stream.
|
// does not actually do asynchronous writes to stream.
|
||||||
await _writer.WriteLineAsync(
|
await _writer.WriteLineAsync(
|
||||||
await PreambleTemplate.RenderAsync(templateContext, cancellationToken)
|
Minify(
|
||||||
|
await PreambleTemplate.RenderAsync(templateContext, cancellationToken)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask WriteMessageGroupAsync(
|
private async ValueTask WriteMessageGroupAsync(
|
||||||
MessageGroup messageGroup,
|
IReadOnlyList<Message> messages,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
var templateContext = new MessageGroupTemplateContext(Context, messages);
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
// We are not writing directly to output because Razor
|
||||||
// does not actually do asynchronous writes to stream.
|
// does not actually do asynchronous writes to stream.
|
||||||
await _writer.WriteLineAsync(
|
await _writer.WriteLineAsync(
|
||||||
await MessageGroupTemplate.RenderAsync(templateContext, cancellationToken)
|
Minify(
|
||||||
|
await MessageGroupTemplate.RenderAsync(templateContext, cancellationToken)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,38 +76,35 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
await base.WriteMessageAsync(message, cancellationToken);
|
await base.WriteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
// If message group is empty or the given message can be grouped, buffer the given message
|
// If the message can be grouped, buffer it for now
|
||||||
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
|
if (CanJoinGroup( message))
|
||||||
{
|
{
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroup.Add(message);
|
||||||
}
|
}
|
||||||
// Otherwise, flush the group and render messages
|
// Otherwise, flush the group and render messages
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken);
|
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
|
||||||
|
|
||||||
_messageGroupBuffer.Clear();
|
_messageGroup.Clear();
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroup.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Flush current message group
|
// Flush current message group
|
||||||
if (_messageGroupBuffer.Any())
|
if (_messageGroup.Any())
|
||||||
{
|
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
|
||||||
await WriteMessageGroupAsync(
|
|
||||||
MessageGroup.Join(_messageGroupBuffer),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
// We are not writing directly to output because Razor
|
||||||
// does not actually do asynchronous writes to stream.
|
// does not actually do asynchronous writes to stream.
|
||||||
await _writer.WriteLineAsync(
|
await _writer.WriteLineAsync(
|
||||||
await PostambleTemplate.RenderAsync(templateContext, cancellationToken)
|
Minify(
|
||||||
|
await PostambleTemplate.RenderAsync(templateContext, cancellationToken)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||||
{
|
{
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
Indented = true,
|
Indented = true,
|
||||||
// Validation errors may mask actual failures
|
// Validation errors may mask actual failures
|
||||||
@@ -50,7 +51,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
EmbedAuthor embedAuthor,
|
EmbedAuthor embedAuthor,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("author");
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("name", embedAuthor.Name);
|
_writer.WriteString("name", embedAuthor.Name);
|
||||||
_writer.WriteString("url", embedAuthor.Url);
|
_writer.WriteString("url", embedAuthor.Url);
|
||||||
@@ -62,27 +63,11 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask WriteEmbedThumbnailAsync(
|
|
||||||
EmbedImage embedThumbnail,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_writer.WriteStartObject("thumbnail");
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedThumbnail.Url))
|
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.ProxyUrl ?? embedThumbnail.Url, cancellationToken));
|
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedThumbnail.Width);
|
|
||||||
_writer.WriteNumber("height", embedThumbnail.Height);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
await _writer.FlushAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask WriteEmbedImageAsync(
|
private async ValueTask WriteEmbedImageAsync(
|
||||||
EmbedImage embedImage,
|
EmbedImage embedImage,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("image");
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
||||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken));
|
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken));
|
||||||
@@ -98,7 +83,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
EmbedFooter embedFooter,
|
EmbedFooter embedFooter,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("footer");
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
_writer.WriteString("text", embedFooter.Text);
|
_writer.WriteString("text", embedFooter.Text);
|
||||||
|
|
||||||
@@ -138,16 +123,37 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
_writer.WriteString("color", embed.Color.Value.ToHex());
|
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||||
|
|
||||||
if (embed.Author is not null)
|
if (embed.Author is not null)
|
||||||
|
{
|
||||||
|
_writer.WritePropertyName("author");
|
||||||
await WriteEmbedAuthorAsync(embed.Author, cancellationToken);
|
await WriteEmbedAuthorAsync(embed.Author, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (embed.Thumbnail is not null)
|
if (embed.Thumbnail is not null)
|
||||||
await WriteEmbedThumbnailAsync(embed.Thumbnail, cancellationToken);
|
{
|
||||||
|
_writer.WritePropertyName("thumbnail");
|
||||||
|
await WriteEmbedImageAsync(embed.Thumbnail, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (embed.Image is not null)
|
// Legacy: backwards-compatibility for old embeds with a single image
|
||||||
await WriteEmbedImageAsync(embed.Image, cancellationToken);
|
if (embed.Images.Count > 0)
|
||||||
|
{
|
||||||
|
_writer.WritePropertyName("image");
|
||||||
|
await WriteEmbedImageAsync(embed.Images[0], cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (embed.Footer is not null)
|
if (embed.Footer is not null)
|
||||||
|
{
|
||||||
|
_writer.WritePropertyName("footer");
|
||||||
await WriteEmbedFooterAsync(embed.Footer, cancellationToken);
|
await WriteEmbedFooterAsync(embed.Footer, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Images
|
||||||
|
_writer.WriteStartArray("images");
|
||||||
|
|
||||||
|
foreach (var image in embed.Images)
|
||||||
|
await WriteEmbedImageAsync(image, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
_writer.WriteStartArray("fields");
|
_writer.WriteStartArray("fields");
|
||||||
|
|||||||
+44
-21
@@ -33,15 +33,37 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
{
|
{
|
||||||
var (tagOpen, tagClose) = formatting.Kind switch
|
var (tagOpen, tagClose) = formatting.Kind switch
|
||||||
{
|
{
|
||||||
FormattingKind.Bold => ("<strong>", "</strong>"),
|
FormattingKind.Bold => (
|
||||||
FormattingKind.Italic => ("<em>", "</em>"),
|
"<strong>",
|
||||||
FormattingKind.Underline => ("<u>", "</u>"),
|
"</strong>"
|
||||||
FormattingKind.Strikethrough => ("<s>", "</s>"),
|
),
|
||||||
|
|
||||||
|
FormattingKind.Italic => (
|
||||||
|
"<em>",
|
||||||
|
"</em>"
|
||||||
|
),
|
||||||
|
|
||||||
|
FormattingKind.Underline => (
|
||||||
|
"<u>",
|
||||||
|
"</u>"
|
||||||
|
),
|
||||||
|
|
||||||
|
FormattingKind.Strikethrough => (
|
||||||
|
"<s>",
|
||||||
|
"</s>"
|
||||||
|
),
|
||||||
|
|
||||||
FormattingKind.Spoiler => (
|
FormattingKind.Spoiler => (
|
||||||
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
|
"<span class=\"chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden\" onclick=\"showSpoiler(event, this)\">",
|
||||||
|
"</span>"
|
||||||
|
),
|
||||||
|
|
||||||
FormattingKind.Quote => (
|
FormattingKind.Quote => (
|
||||||
"<div class=\"quote\"><div class=\"quote-border\"></div><div class=\"quote-content\">", "</div></div>"),
|
"<div class=\"chatlog__markdown-quote\"><div class=\"chatlog__markdown-quote-border\"></div><div class=\"chatlog__markdown-quote-content\">",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
|
"</div></div>"
|
||||||
|
),
|
||||||
|
|
||||||
|
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
|
||||||
};
|
};
|
||||||
|
|
||||||
_buffer.Append(tagOpen);
|
_buffer.Append(tagOpen);
|
||||||
@@ -54,9 +76,9 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"pre pre--inline\">")
|
.Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
|
||||||
.Append(HtmlEncode(inlineCodeBlock.Code))
|
.Append(HtmlEncode(inlineCodeBlock.Code))
|
||||||
.Append("</span>");
|
.Append("</code>");
|
||||||
|
|
||||||
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
return base.VisitInlineCodeBlock(inlineCodeBlock);
|
||||||
}
|
}
|
||||||
@@ -68,9 +90,9 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
: "nohighlight";
|
: "nohighlight";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<div class=\"pre pre--multiline {highlightCssClass}\">")
|
.Append($"<code class=\"chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightCssClass}\">")
|
||||||
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
||||||
.Append("</div>");
|
.Append("</code>");
|
||||||
|
|
||||||
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
|
||||||
}
|
}
|
||||||
@@ -98,10 +120,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||||
{
|
{
|
||||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||||
var jumboClass = _isJumbo ? "emoji--large" : "";
|
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<img loading=\"lazy\" class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
.Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
||||||
|
|
||||||
return base.VisitEmoji(emoji);
|
return base.VisitEmoji(emoji);
|
||||||
}
|
}
|
||||||
@@ -111,14 +133,14 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
if (mention.Kind == MentionKind.Everyone)
|
if (mention.Kind == MentionKind.Everyone)
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"mention\">")
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
.Append("@everyone")
|
.Append("@everyone")
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Here)
|
else if (mention.Kind == MentionKind.Here)
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"mention\">")
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
.Append("@here")
|
.Append("@here")
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
@@ -129,18 +151,18 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
var nick = member?.Nick ?? "Unknown";
|
var nick = member?.Nick ?? "Unknown";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
|
.Append($"<span class=\"chatlog__markdown-mention\" title=\"{HtmlEncode(fullName)}\">")
|
||||||
.Append('@').Append(HtmlEncode(nick))
|
.Append('@').Append(HtmlEncode(nick))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
||||||
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
|
var symbol = channel?.SupportsVoice == true ? "🔊" : "#";
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"mention\">")
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
.Append(symbol).Append(HtmlEncode(name))
|
.Append(symbol).Append(HtmlEncode(name))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
@@ -151,11 +173,12 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
var color = role?.Color;
|
var color = role?.Color;
|
||||||
|
|
||||||
var style = color is not null
|
var style = color is not null
|
||||||
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
|
? $"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);"
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"mention\" style=\"{style}\">")
|
.Append($"<span class=\"chatlog__markdown-mention\" style=\"{style}\">")
|
||||||
.Append('@').Append(HtmlEncode(name))
|
.Append('@').Append(HtmlEncode(name))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
@@ -175,7 +198,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
: "Invalid date";
|
: "Invalid date";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
.Append($"<span class=\"chatlog__markdown-timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
||||||
.Append(HtmlEncode(dateString))
|
.Append(HtmlEncode(dateString))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -52,13 +52,13 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
var channel =mention.TargetId?.Pipe(_context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer.Append($"#{name}");
|
_buffer.Append($"#{name}");
|
||||||
|
|
||||||
// Voice channel marker
|
// Voice channel marker
|
||||||
if (channel?.IsVoiceChannel == true)
|
if (channel?.SupportsVoice == true)
|
||||||
_buffer.Append(" [voice]");
|
_buffer.Append(" [voice]");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Role)
|
else if (mention.Kind == MentionKind.Role)
|
||||||
|
|||||||
@@ -88,8 +88,11 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken));
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
|
foreach (var image in embed.Images)
|
||||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url, cancellationToken));
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(image.Url))
|
||||||
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(image.ProxyUrl ?? image.Url, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||||
await _writer.WriteLineAsync(embed.Footer.Text);
|
await _writer.WriteLineAsync(embed.Footer.Text);
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ internal static partial class MarkdownParser
|
|||||||
|
|
||||||
// Capture any character until the end of the line
|
// Capture any character until the end of the line
|
||||||
// Opening 'greater than' character must be followed by whitespace
|
// Opening 'greater than' character must be followed by whitespace
|
||||||
|
// Text content is optional
|
||||||
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("^>\\s(.+\n?)", DefaultRegexOptions),
|
new Regex("^>\\s(.*\n?)", DefaultRegexOptions),
|
||||||
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -86,7 +87,7 @@ internal static partial class MarkdownParser
|
|||||||
// This one is tricky as it ends up producing multiple separate captures which need to be joined
|
// This one is tricky as it ends up producing multiple separate captures which need to be joined
|
||||||
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions),
|
new Regex("(?:^>\\s(.*\n?)){2,}", DefaultRegexOptions),
|
||||||
(_, m) =>
|
(_, m) =>
|
||||||
{
|
{
|
||||||
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
|
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
|
public static class CollectionExtensions
|
||||||
|
{
|
||||||
|
public static IEnumerable<T> Enumerate<T>(this T obj)
|
||||||
|
{
|
||||||
|
yield return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source)
|
||||||
|
{
|
||||||
|
var i = 0;
|
||||||
|
foreach (var o in source)
|
||||||
|
yield return (o, i++);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
|
public static class ExceptionExtensions
|
||||||
|
{
|
||||||
|
private static void PopulateChildren(this Exception exception, ICollection<Exception> children)
|
||||||
|
{
|
||||||
|
if (exception is AggregateException aggregateException)
|
||||||
|
{
|
||||||
|
foreach (var innerException in aggregateException.InnerExceptions)
|
||||||
|
{
|
||||||
|
children.Add(innerException);
|
||||||
|
PopulateChildren(innerException, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (exception.InnerException is not null)
|
||||||
|
{
|
||||||
|
children.Add(exception.InnerException);
|
||||||
|
PopulateChildren(exception.InnerException, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
|
||||||
|
{
|
||||||
|
var children = new List<Exception> {exception};
|
||||||
|
PopulateChildren(exception, children);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HttpStatusCode? TryGetStatusCode(this HttpRequestException ex) =>
|
||||||
|
// This is extremely frail, but there's no other way
|
||||||
|
Regex
|
||||||
|
.Match(ex.Message, @": (\d+) \(")
|
||||||
|
.Groups[1]
|
||||||
|
.Value
|
||||||
|
.NullIfWhiteSpace()?
|
||||||
|
.Pipe(s => (HttpStatusCode) int.Parse(s, CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Linq;
|
||||||
using System.IO;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Text.RegularExpressions;
|
using System.Net.Sockets;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using Polly;
|
using Polly;
|
||||||
@@ -14,13 +13,26 @@ public static class Http
|
|||||||
{
|
{
|
||||||
public static HttpClient Client { get; } = new();
|
public static HttpClient Client { get; } = new();
|
||||||
|
|
||||||
public static IAsyncPolicy<HttpResponseMessage> ResponsePolicy { get; } =
|
private static bool IsRetryableStatusCode(HttpStatusCode statusCode) => statusCode is
|
||||||
|
HttpStatusCode.TooManyRequests or
|
||||||
|
HttpStatusCode.RequestTimeout or
|
||||||
|
HttpStatusCode.InternalServerError;
|
||||||
|
|
||||||
|
private static bool IsRetryableException(Exception exception) =>
|
||||||
|
exception.GetSelfAndChildren().Any(ex =>
|
||||||
|
ex is TimeoutException or SocketException ||
|
||||||
|
ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK)
|
||||||
|
);
|
||||||
|
|
||||||
|
public static IAsyncPolicy ResiliencePolicy { get; } =
|
||||||
Policy
|
Policy
|
||||||
.Handle<IOException>()
|
.Handle<Exception>(IsRetryableException)
|
||||||
.Or<HttpRequestException>()
|
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
|
||||||
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
|
|
||||||
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
|
public static IAsyncPolicy<HttpResponseMessage> ResponseResiliencePolicy { get; } =
|
||||||
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
|
Policy
|
||||||
|
.Handle<Exception>(IsRetryableException)
|
||||||
|
.OrResult<HttpResponseMessage>(m => IsRetryableStatusCode(m.StatusCode))
|
||||||
.WaitAndRetryAsync(
|
.WaitAndRetryAsync(
|
||||||
8,
|
8,
|
||||||
(i, result, _) =>
|
(i, result, _) =>
|
||||||
@@ -43,24 +55,4 @@ public static class Http
|
|||||||
},
|
},
|
||||||
(_, _, _, _) => Task.CompletedTask
|
(_, _, _, _) => Task.CompletedTask
|
||||||
);
|
);
|
||||||
|
|
||||||
private static HttpStatusCode? TryGetStatusCodeFromException(HttpRequestException ex) =>
|
|
||||||
// This is extremely frail, but there's no other way
|
|
||||||
Regex
|
|
||||||
.Match(ex.Message, @": (\d+) \(")
|
|
||||||
.Groups[1]
|
|
||||||
.Value
|
|
||||||
.NullIfWhiteSpace()?
|
|
||||||
.Pipe(s => (HttpStatusCode) int.Parse(s, CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
public static IAsyncPolicy ExceptionPolicy { get; } =
|
|
||||||
Policy
|
|
||||||
.Handle<IOException>() // dangerous
|
|
||||||
.Or<HttpRequestException>(ex =>
|
|
||||||
TryGetStatusCodeFromException(ex) is
|
|
||||||
HttpStatusCode.TooManyRequests or
|
|
||||||
HttpStatusCode.RequestTimeout or
|
|
||||||
HttpStatusCode.InternalServerError
|
|
||||||
)
|
|
||||||
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
|
|
||||||
}
|
}
|
||||||
@@ -47,6 +47,29 @@
|
|||||||
<Setter Property="Minimum" Value="0" />
|
<Setter Property="Minimum" Value="0" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="{x:Type Hyperlink}">
|
||||||
|
<Setter Property="TextDecorations" Value="Underline" />
|
||||||
|
<Setter Property="FontWeight" Value="Regular" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignBody}" />
|
||||||
|
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="True">
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Trigger>
|
||||||
|
<MultiTrigger>
|
||||||
|
<MultiTrigger.Conditions>
|
||||||
|
<Condition Property="IsEnabled" Value="True" />
|
||||||
|
<Condition Property="IsMouseOver" Value="True" />
|
||||||
|
</MultiTrigger.Conditions>
|
||||||
|
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
|
||||||
|
</MultiTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
||||||
@@ -90,6 +113,10 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignContextMenu}" TargetType="{x:Type ContextMenu}">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource MaterialDesignPaper}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
||||||
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ namespace DiscordChatExporter.Gui.Behaviors;
|
|||||||
|
|
||||||
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
||||||
{
|
{
|
||||||
public static readonly DependencyProperty SelectedItemsProperty =
|
public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
|
||||||
DependencyProperty.Register(nameof(SelectedItems), typeof(IList),
|
nameof(SelectedItems),
|
||||||
typeof(MultiSelectionListBoxBehavior<T>),
|
typeof(IList),
|
||||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
|
typeof(MultiSelectionListBoxBehavior<T>),
|
||||||
OnSelectedItemsChanged));
|
new FrameworkPropertyMetadata(
|
||||||
|
null,
|
||||||
|
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
|
||||||
|
OnSelectedItemsChanged
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
|
private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,19 +9,13 @@ public class DateTimeOffsetToDateTimeConverter : IValueConverter
|
|||||||
{
|
{
|
||||||
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
|
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
|
||||||
|
|
||||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
{
|
value is DateTimeOffset dateTimeOffsetValue
|
||||||
if (value is DateTimeOffset dateTimeOffsetValue)
|
? dateTimeOffsetValue.DateTime
|
||||||
return dateTimeOffsetValue.DateTime;
|
: default(DateTime?);
|
||||||
|
|
||||||
return default(DateTime?);
|
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
}
|
value is DateTime dateTimeValue
|
||||||
|
? new DateTimeOffset(dateTimeValue)
|
||||||
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
: default(DateTimeOffset?);
|
||||||
{
|
|
||||||
if (value is DateTime dateTimeValue)
|
|
||||||
return new DateTimeOffset(dateTimeValue);
|
|
||||||
|
|
||||||
return default(DateTimeOffset?);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,10 @@ public class ExportFormatToStringConverter : IValueConverter
|
|||||||
{
|
{
|
||||||
public static ExportFormatToStringConverter Instance { get; } = new();
|
public static ExportFormatToStringConverter Instance { get; } = new();
|
||||||
|
|
||||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
{
|
value is ExportFormat exportFormatValue
|
||||||
if (value is ExportFormat exportFormatValue)
|
? exportFormatValue.GetDisplayName()
|
||||||
return exportFormatValue.GetDisplayName();
|
: default;
|
||||||
|
|
||||||
return default(string?);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|||||||
@@ -9,19 +9,9 @@ public class InverseBoolConverter : IValueConverter
|
|||||||
{
|
{
|
||||||
public static InverseBoolConverter Instance { get; } = new();
|
public static InverseBoolConverter Instance { get; } = new();
|
||||||
|
|
||||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
{
|
value is false;
|
||||||
if (value is bool boolValue)
|
|
||||||
return !boolValue;
|
|
||||||
|
|
||||||
return default(bool);
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
}
|
value is false;
|
||||||
|
|
||||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
if (value is bool boolValue)
|
|
||||||
return !boolValue;
|
|
||||||
|
|
||||||
return default(bool);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Windows.Data;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Converters;
|
||||||
|
|
||||||
|
[ValueConversion(typeof(Snowflake?), typeof(DateTimeOffset?))]
|
||||||
|
public class SnowflakeToDateTimeOffsetConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
|
value is Snowflake snowflake
|
||||||
|
? snowflake.ToDate()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
|
throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -9,19 +9,13 @@ public class TimeSpanToDateTimeConverter : IValueConverter
|
|||||||
{
|
{
|
||||||
public static TimeSpanToDateTimeConverter Instance { get; } = new();
|
public static TimeSpanToDateTimeConverter Instance { get; } = new();
|
||||||
|
|
||||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
{
|
value is TimeSpan timeSpanValue
|
||||||
if (value is TimeSpan timeSpanValue)
|
? DateTime.Today.Add(timeSpanValue)
|
||||||
return DateTime.Today.Add(timeSpanValue);
|
: default(DateTime?);
|
||||||
|
|
||||||
return default(DateTime?);
|
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
}
|
value is DateTime dateTimeValue
|
||||||
|
? dateTimeValue.TimeOfDay
|
||||||
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
: default(TimeSpan?);
|
||||||
{
|
|
||||||
if (value is DateTime dateTimeValue)
|
|
||||||
return dateTimeValue.TimeOfDay;
|
|
||||||
|
|
||||||
return default(TimeSpan?);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -14,15 +14,15 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Gress" Version="2.0.1" />
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
<PackageReference Include="MaterialDesignColors" Version="2.0.4" />
|
<PackageReference Include="MaterialDesignColors" Version="2.0.6" />
|
||||||
<PackageReference Include="MaterialDesignThemes" Version="4.3.0" />
|
<PackageReference Include="MaterialDesignThemes" Version="4.5.0" />
|
||||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
||||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
||||||
<PackageReference Include="Onova" Version="2.6.2" />
|
<PackageReference Include="Onova" Version="2.6.2" />
|
||||||
<PackageReference Include="Stylet" Version="1.3.6" />
|
<PackageReference Include="Stylet" Version="1.3.6" />
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||||
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" PrivateAssets="all" />
|
<PackageReference Include="PropertyChanged.Fody" Version="3.4.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.2.0" PrivateAssets="all" />
|
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ public partial class SettingsService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
return Registry.CurrentUser.OpenSubKey(
|
return Registry.CurrentUser.OpenSubKey(
|
||||||
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", false
|
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||||
|
false
|
||||||
)?.GetValue("AppsUseLightTheme") is 0;
|
)?.GetValue("AppsUseLightTheme") is 0;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ internal static class ProcessEx
|
|||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
};
|
};
|
||||||
|
|
||||||
using (Process.Start(startInfo)) {}
|
using (Process.Start(startInfo))
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using DiscordChatExporter.Gui.Services;
|
||||||
|
using DiscordChatExporter.Gui.Utils;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Messages;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
|
using Gress;
|
||||||
|
using Gress.Completable;
|
||||||
|
using Stylet;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
|
|
||||||
|
public class DashboardViewModel : PropertyChangedBase
|
||||||
|
{
|
||||||
|
private readonly IViewModelFactory _viewModelFactory;
|
||||||
|
private readonly IEventAggregator _eventAggregator;
|
||||||
|
private readonly DialogManager _dialogManager;
|
||||||
|
private readonly SettingsService _settingsService;
|
||||||
|
|
||||||
|
private readonly AutoResetProgressMuxer _progressMuxer;
|
||||||
|
|
||||||
|
private DiscordClient? _discord;
|
||||||
|
|
||||||
|
public bool IsBusy { get; private set; }
|
||||||
|
|
||||||
|
public ProgressContainer<Percentage> Progress { get; } = new();
|
||||||
|
|
||||||
|
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
||||||
|
|
||||||
|
public string? Token { get; set; }
|
||||||
|
|
||||||
|
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.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]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
|
||||||
|
|
||||||
|
public DashboardViewModel(
|
||||||
|
IViewModelFactory viewModelFactory,
|
||||||
|
IEventAggregator eventAggregator,
|
||||||
|
DialogManager dialogManager,
|
||||||
|
SettingsService settingsService)
|
||||||
|
{
|
||||||
|
_viewModelFactory = viewModelFactory;
|
||||||
|
_eventAggregator = eventAggregator;
|
||||||
|
_dialogManager = dialogManager;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
||||||
|
|
||||||
|
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||||
|
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnViewLoaded()
|
||||||
|
{
|
||||||
|
if (_settingsService.LastToken is not null)
|
||||||
|
{
|
||||||
|
Token = _settingsService.LastToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void ShowSettings()
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateSettingsViewModel();
|
||||||
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
|
||||||
|
|
||||||
|
public bool CanPopulateGuildsAndChannels =>
|
||||||
|
!IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||||
|
|
||||||
|
public async void PopulateGuildsAndChannels()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
var progress = _progressMuxer.CreateInput();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = Token?.Trim('"', ' ');
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_settingsService.LastToken = token;
|
||||||
|
|
||||||
|
var discord = new DiscordClient(token);
|
||||||
|
|
||||||
|
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||||
|
await foreach (var guild in discord.GetUserGuildsAsync())
|
||||||
|
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
|
||||||
|
|
||||||
|
_discord = discord;
|
||||||
|
GuildChannelMap = guildChannelMap;
|
||||||
|
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
|
{
|
||||||
|
_eventAggregator.Publish(
|
||||||
|
new NotificationMessage(ex.Message.TrimEnd('.'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
||||||
|
"Error pulling guilds and channels",
|
||||||
|
ex.ToString()
|
||||||
|
);
|
||||||
|
|
||||||
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
progress.ReportCompletion();
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExportChannels =>
|
||||||
|
!IsBusy &&
|
||||||
|
_discord is not null &&
|
||||||
|
SelectedGuild is not null &&
|
||||||
|
SelectedChannels?.Any() is true;
|
||||||
|
|
||||||
|
public async void ExportChannels()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
|
||||||
|
if (await _dialogManager.ShowDialogAsync(dialog) != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var exporter = new ChannelExporter(_discord);
|
||||||
|
|
||||||
|
var progresses = Enumerable
|
||||||
|
.Range(0, dialog.Channels!.Count)
|
||||||
|
.Select(_ => _progressMuxer.CreateInput())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var successfulExportCount = 0;
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(
|
||||||
|
dialog.Channels.Zip(progresses),
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
|
||||||
|
},
|
||||||
|
async (tuple, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var (channel, progress) = tuple;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new ExportRequest(
|
||||||
|
dialog.Guild!,
|
||||||
|
channel,
|
||||||
|
dialog.OutputPath!,
|
||||||
|
dialog.SelectedFormat,
|
||||||
|
dialog.After?.Pipe(Snowflake.FromDate),
|
||||||
|
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||||
|
dialog.PartitionLimit,
|
||||||
|
dialog.MessageFilter,
|
||||||
|
dialog.ShouldDownloadMedia,
|
||||||
|
_settingsService.ShouldReuseMedia,
|
||||||
|
_settingsService.DateFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
await exporter.ExportChannelAsync(request, progress, cancellationToken);
|
||||||
|
|
||||||
|
Interlocked.Increment(ref successfulExportCount);
|
||||||
|
}
|
||||||
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
|
{
|
||||||
|
_eventAggregator.Publish(
|
||||||
|
new NotificationMessage(ex.Message.TrimEnd('.') + $" ({channel.Name})")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
progress.ReportCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify of overall completion
|
||||||
|
if (successfulExportCount > 0)
|
||||||
|
{
|
||||||
|
_eventAggregator.Publish(
|
||||||
|
new NotificationMessage($"Successfully exported {successfulExportCount} channel(s)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
||||||
|
"Error exporting channel(s)",
|
||||||
|
ex.ToString()
|
||||||
|
);
|
||||||
|
|
||||||
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenDiscord() =>
|
||||||
|
ProcessEx.StartShellExecute("https://discord.com/app");
|
||||||
|
|
||||||
|
public void OpenDiscordDeveloperPortal() =>
|
||||||
|
ProcessEx.StartShellExecute("https://discord.com/developers/applications");
|
||||||
|
}
|
||||||
@@ -21,12 +21,11 @@ public class ExportSetupViewModel : DialogScreen
|
|||||||
|
|
||||||
public IReadOnlyList<Channel>? Channels { get; set; }
|
public IReadOnlyList<Channel>? Channels { get; set; }
|
||||||
|
|
||||||
public bool IsSingleChannel => Channels is null || Channels.Count == 1;
|
public bool IsSingleChannel => Channels?.Count == 1;
|
||||||
|
|
||||||
public string? OutputPath { get; set; }
|
public string? OutputPath { get; set; }
|
||||||
|
|
||||||
public IReadOnlyList<ExportFormat> AvailableFormats =>
|
public IReadOnlyList<ExportFormat> AvailableFormats { get; } = Enum.GetValues<ExportFormat>();
|
||||||
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
|
||||||
|
|
||||||
public ExportFormat SelectedFormat { get; set; }
|
public ExportFormat SelectedFormat { get; set; }
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ public class ExportSetupViewModel : DialogScreen
|
|||||||
MessageFilterValue = _settingsService.LastMessageFilterValue;
|
MessageFilterValue = _settingsService.LastMessageFilterValue;
|
||||||
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
|
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
|
||||||
|
|
||||||
// Show the "advanced options" by default if any
|
// Show the "advanced options" section by default if any
|
||||||
// of the advanced options are set to non-default values.
|
// of the advanced options are set to non-default values.
|
||||||
IsAdvancedSectionDisplayed =
|
IsAdvancedSectionDisplayed =
|
||||||
After != default ||
|
After != default ||
|
||||||
@@ -96,12 +95,11 @@ public class ExportSetupViewModel : DialogScreen
|
|||||||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||||
|
|
||||||
// If single channel - prompt file path
|
// If single channel - prompt file path
|
||||||
if (Channels is not null && IsSingleChannel)
|
if (IsSingleChannel)
|
||||||
{
|
{
|
||||||
var channel = Channels.Single();
|
|
||||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
||||||
Guild!,
|
Guild!,
|
||||||
channel,
|
Channels!.Single(),
|
||||||
SelectedFormat,
|
SelectedFormat,
|
||||||
After?.Pipe(Snowflake.FromDate),
|
After?.Pipe(Snowflake.FromDate),
|
||||||
Before?.Pipe(Snowflake.FromDate)
|
Before?.Pipe(Snowflake.FromDate)
|
||||||
|
|||||||
@@ -7,20 +7,41 @@ public class MessageBoxViewModel : DialogScreen
|
|||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
|
|
||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
public bool IsOkButtonVisible { get; set; } = true;
|
||||||
|
|
||||||
|
public string? OkButtonText { get; set; }
|
||||||
|
|
||||||
|
public bool IsCancelButtonVisible { get; set; }
|
||||||
|
|
||||||
|
public string? CancelButtonText { get; set; }
|
||||||
|
|
||||||
|
public int ButtonsCount =>
|
||||||
|
(IsOkButtonVisible ? 1 : 0) +
|
||||||
|
(IsCancelButtonVisible ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class MessageBoxViewModelExtensions
|
public static class MessageBoxViewModelExtensions
|
||||||
{
|
{
|
||||||
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
||||||
this IViewModelFactory factory,
|
this IViewModelFactory factory,
|
||||||
string title,
|
string title, string message,
|
||||||
string message)
|
string? okButtonText, string? cancelButtonText)
|
||||||
{
|
{
|
||||||
var viewModel = factory.CreateMessageBoxViewModel();
|
var viewModel = factory.CreateMessageBoxViewModel();
|
||||||
|
|
||||||
viewModel.Title = title;
|
viewModel.Title = title;
|
||||||
viewModel.Message = message;
|
viewModel.Message = message;
|
||||||
|
|
||||||
|
viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText);
|
||||||
|
viewModel.OkButtonText = okButtonText;
|
||||||
|
viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText);
|
||||||
|
viewModel.CancelButtonText = cancelButtonText;
|
||||||
|
|
||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
||||||
|
this IViewModelFactory factory,
|
||||||
|
string title, string message) =>
|
||||||
|
factory.CreateMessageBoxViewModel(title, message, "CLOSE", null);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MaterialDesignThemes.Wpf;
|
using MaterialDesignThemes.Wpf;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
@@ -8,9 +9,10 @@ using Stylet;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
|
|
||||||
public class DialogManager
|
public class DialogManager : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IViewManager _viewManager;
|
private readonly IViewManager _viewManager;
|
||||||
|
private readonly SemaphoreSlim _dialogLock = new(1, 1);
|
||||||
|
|
||||||
public DialogManager(IViewManager viewManager)
|
public DialogManager(IViewManager viewManager)
|
||||||
{
|
{
|
||||||
@@ -21,20 +23,26 @@ public class DialogManager
|
|||||||
{
|
{
|
||||||
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
||||||
|
|
||||||
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
|
void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
|
||||||
{
|
{
|
||||||
void OnScreenClosed(object? o, EventArgs closeArgs)
|
void OnScreenClosed(object? closeSender, EventArgs args)
|
||||||
{
|
{
|
||||||
openArgs.Session.Close();
|
openArgs.Session.Close();
|
||||||
dialogScreen.Closed -= OnScreenClosed;
|
dialogScreen.Closed -= OnScreenClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialogScreen.Closed += OnScreenClosed;
|
dialogScreen.Closed += OnScreenClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
await DialogHost.Show(view, OnDialogOpened);
|
await _dialogLock.WaitAsync();
|
||||||
|
try
|
||||||
return dialogScreen.DialogResult;
|
{
|
||||||
|
await DialogHost.Show(view, OnDialogOpened);
|
||||||
|
return dialogScreen.DialogResult;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dialogLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
||||||
@@ -59,4 +67,9 @@ public class DialogManager
|
|||||||
|
|
||||||
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
|
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_dialogLock.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
|
|
||||||
// Used to instantiate new view models while making use of dependency injection
|
// Used to instantiate new view models while making use of dependency injection
|
||||||
public interface IViewModelFactory
|
public interface IViewModelFactory
|
||||||
{
|
{
|
||||||
|
DashboardViewModel CreateDashboardViewModel();
|
||||||
|
|
||||||
ExportSetupViewModel CreateExportSetupViewModel();
|
ExportSetupViewModel CreateExportSetupViewModel();
|
||||||
|
|
||||||
MessageBoxViewModel CreateMessageBoxViewModel();
|
MessageBoxViewModel CreateMessageBoxViewModel();
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.ViewModels.Messages;
|
||||||
|
|
||||||
|
public class NotificationMessage
|
||||||
|
{
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public NotificationMessage(string text) => Text = text;
|
||||||
|
}
|
||||||
@@ -1,59 +1,30 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
|
||||||
using DiscordChatExporter.Core.Exporting;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
using DiscordChatExporter.Gui.Utils;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Messages;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
using Gress;
|
|
||||||
using Gress.Completable;
|
|
||||||
using MaterialDesignThemes.Wpf;
|
using MaterialDesignThemes.Wpf;
|
||||||
using Stylet;
|
using Stylet;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels;
|
namespace DiscordChatExporter.Gui.ViewModels;
|
||||||
|
|
||||||
public class RootViewModel : Screen
|
public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IViewModelFactory _viewModelFactory;
|
private readonly IViewModelFactory _viewModelFactory;
|
||||||
private readonly DialogManager _dialogManager;
|
private readonly DialogManager _dialogManager;
|
||||||
private readonly SettingsService _settingsService;
|
private readonly SettingsService _settingsService;
|
||||||
private readonly UpdateService _updateService;
|
private readonly UpdateService _updateService;
|
||||||
|
|
||||||
private readonly AutoResetProgressMuxer _progressMuxer;
|
|
||||||
|
|
||||||
private DiscordClient? _discord;
|
|
||||||
|
|
||||||
public bool IsBusy { get; private set; }
|
|
||||||
|
|
||||||
public ProgressContainer<Percentage> Progress { get; } = new();
|
|
||||||
|
|
||||||
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
|
||||||
|
|
||||||
public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
|
public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
public string? Token { get; set; }
|
public DashboardViewModel Dashboard { get; }
|
||||||
|
|
||||||
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.Keys.ToArray();
|
|
||||||
|
|
||||||
public Guild? SelectedGuild { get; set; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
|
|
||||||
? GuildChannelMap?[SelectedGuild]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
|
|
||||||
|
|
||||||
public RootViewModel(
|
public RootViewModel(
|
||||||
IViewModelFactory viewModelFactory,
|
IViewModelFactory viewModelFactory,
|
||||||
|
IEventAggregator eventAggregator,
|
||||||
DialogManager dialogManager,
|
DialogManager dialogManager,
|
||||||
SettingsService settingsService,
|
SettingsService settingsService,
|
||||||
UpdateService updateService)
|
UpdateService updateService)
|
||||||
@@ -62,15 +33,31 @@ public class RootViewModel : Screen
|
|||||||
_dialogManager = dialogManager;
|
_dialogManager = dialogManager;
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_updateService = updateService;
|
_updateService = updateService;
|
||||||
|
|
||||||
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
eventAggregator.Subscribe(this);
|
||||||
|
|
||||||
|
Dashboard = _viewModelFactory.CreateDashboardViewModel();
|
||||||
|
|
||||||
DisplayName = $"{App.Name} v{App.VersionString}";
|
DisplayName = $"{App.Name} v{App.VersionString}";
|
||||||
|
|
||||||
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
|
||||||
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ShowWarInUkraineMessageAsync()
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
||||||
|
"Ukraine is at war!", @"
|
||||||
|
My country, Ukraine, has been invaded by Russian military forces in an act of aggression that can only be described as genocide.
|
||||||
|
Be on the right side of history! Consider supporting Ukraine in its fight for freedom.
|
||||||
|
|
||||||
|
Press LEARN MORE to find ways that you can help.".Trim(),
|
||||||
|
"LEARN MORE", "CLOSE"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await _dialogManager.ShowDialogAsync(dialog) == true)
|
||||||
|
{
|
||||||
|
ProcessEx.StartShellExecute("https://tyrrrz.me");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask CheckForUpdatesAsync()
|
private async ValueTask CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -97,18 +84,19 @@ public class RootViewModel : Screen
|
|||||||
Notifications.Enqueue("Failed to perform application update");
|
Notifications.Enqueue("Failed to perform application update");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async void OnViewFullyLoaded()
|
||||||
|
{
|
||||||
|
await ShowWarInUkraineMessageAsync();
|
||||||
|
await CheckForUpdatesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
protected override async void OnViewLoaded()
|
protected override void OnViewLoaded()
|
||||||
{
|
{
|
||||||
base.OnViewLoaded();
|
base.OnViewLoaded();
|
||||||
|
|
||||||
_settingsService.Load();
|
_settingsService.Load();
|
||||||
|
|
||||||
if (_settingsService.LastToken is not null)
|
|
||||||
{
|
|
||||||
Token = _settingsService.LastToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_settingsService.IsDarkModeEnabled)
|
if (_settingsService.IsDarkModeEnabled)
|
||||||
{
|
{
|
||||||
App.SetDarkTheme();
|
App.SetDarkTheme();
|
||||||
@@ -117,21 +105,6 @@ public class RootViewModel : Screen
|
|||||||
{
|
{
|
||||||
App.SetLightTheme();
|
App.SetLightTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
// War in Ukraine message
|
|
||||||
Notifications.Enqueue(
|
|
||||||
"⚠ Ukraine is at war! Support my country in its fight for freedom",
|
|
||||||
"LEARN MORE", _ =>
|
|
||||||
{
|
|
||||||
ProcessEx.StartShellExecute("https://tyrrrz.me");
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
TimeSpan.FromMinutes(1)
|
|
||||||
);
|
|
||||||
|
|
||||||
await CheckForUpdatesAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClose()
|
protected override void OnClose()
|
||||||
@@ -142,149 +115,8 @@ public class RootViewModel : Screen
|
|||||||
_updateService.FinalizeUpdate(false);
|
_updateService.FinalizeUpdate(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void ShowSettings()
|
public void Handle(NotificationMessage message) =>
|
||||||
{
|
Notifications.Enqueue(message.Text);
|
||||||
var dialog = _viewModelFactory.CreateSettingsViewModel();
|
|
||||||
await _dialogManager.ShowDialogAsync(dialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
|
public void Dispose() => Notifications.Dispose();
|
||||||
|
|
||||||
public bool CanPopulateGuildsAndChannels =>
|
|
||||||
!IsBusy && !string.IsNullOrWhiteSpace(Token);
|
|
||||||
|
|
||||||
public async void PopulateGuildsAndChannels()
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
var progress = _progressMuxer.CreateInput();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var token = Token?.Trim('"', ' ');
|
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
|
||||||
return;
|
|
||||||
|
|
||||||
_settingsService.LastToken = token;
|
|
||||||
|
|
||||||
var discord = new DiscordClient(token);
|
|
||||||
|
|
||||||
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
|
||||||
await foreach (var guild in discord.GetUserGuildsAsync())
|
|
||||||
{
|
|
||||||
var channels = await discord.GetGuildChannelsAsync(guild.Id);
|
|
||||||
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
_discord = discord;
|
|
||||||
GuildChannelMap = guildChannelMap;
|
|
||||||
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
|
|
||||||
}
|
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
|
||||||
{
|
|
||||||
Notifications.Enqueue(ex.Message.TrimEnd('.'));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
|
||||||
"Error pulling guilds and channels",
|
|
||||||
ex.ToString()
|
|
||||||
);
|
|
||||||
|
|
||||||
await _dialogManager.ShowDialogAsync(dialog);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
progress.ReportCompletion();
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CanExportChannels =>
|
|
||||||
!IsBusy &&
|
|
||||||
_discord is not null &&
|
|
||||||
SelectedGuild is not null &&
|
|
||||||
SelectedChannels is not null &&
|
|
||||||
SelectedChannels.Any();
|
|
||||||
|
|
||||||
public async void ExportChannels()
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
|
|
||||||
if (await _dialogManager.ShowDialogAsync(dialog) != true)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var exporter = new ChannelExporter(_discord);
|
|
||||||
|
|
||||||
var progresses = Enumerable
|
|
||||||
.Range(0, dialog.Channels!.Count)
|
|
||||||
.Select(_ => _progressMuxer.CreateInput())
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
var successfulExportCount = 0;
|
|
||||||
|
|
||||||
await Parallel.ForEachAsync(
|
|
||||||
dialog.Channels.Zip(progresses),
|
|
||||||
new ParallelOptions
|
|
||||||
{
|
|
||||||
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
|
|
||||||
},
|
|
||||||
async (tuple, cancellationToken) =>
|
|
||||||
{
|
|
||||||
var (channel, progress) = tuple;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = new ExportRequest(
|
|
||||||
dialog.Guild!,
|
|
||||||
channel,
|
|
||||||
dialog.OutputPath!,
|
|
||||||
dialog.SelectedFormat,
|
|
||||||
dialog.After?.Pipe(Snowflake.FromDate),
|
|
||||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
|
||||||
dialog.PartitionLimit,
|
|
||||||
dialog.MessageFilter,
|
|
||||||
dialog.ShouldDownloadMedia,
|
|
||||||
_settingsService.ShouldReuseMedia,
|
|
||||||
_settingsService.DateFormat
|
|
||||||
);
|
|
||||||
|
|
||||||
await exporter.ExportChannelAsync(request, progress, cancellationToken);
|
|
||||||
|
|
||||||
Interlocked.Increment(ref successfulExportCount);
|
|
||||||
}
|
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
|
||||||
{
|
|
||||||
Notifications.Enqueue(ex.Message.TrimEnd('.'));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
progress.ReportCompletion();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify of overall completion
|
|
||||||
if (successfulExportCount > 0)
|
|
||||||
Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
|
||||||
"Error exporting channel(s)",
|
|
||||||
ex.ToString()
|
|
||||||
);
|
|
||||||
|
|
||||||
await _dialogManager.ShowDialogAsync(dialog);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||||
|
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
||||||
|
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
|
||||||
|
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
|
||||||
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core"
|
||||||
|
xmlns:globalization="clr-namespace:System.Globalization;assembly=System.Runtime"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
|
d:DataContext="{d:DesignInstance Type=components:DashboardViewModel}"
|
||||||
|
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
|
||||||
|
Loaded="{s:Action OnViewLoaded}"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<!-- Sort DMs by last message -->
|
||||||
|
<CollectionViewSource x:Key="AvailableDirectMessageChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
||||||
|
<CollectionViewSource.SortDescriptions>
|
||||||
|
<componentModel:SortDescription Direction="Descending" PropertyName="LastMessageId" />
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
|
||||||
|
</CollectionViewSource.SortDescriptions>
|
||||||
|
</CollectionViewSource>
|
||||||
|
<!-- Sort guild channels by position -->
|
||||||
|
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
||||||
|
<CollectionViewSource.GroupDescriptions>
|
||||||
|
<PropertyGroupDescription PropertyName="Category.Name" />
|
||||||
|
</CollectionViewSource.GroupDescriptions>
|
||||||
|
<CollectionViewSource.SortDescriptions>
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
|
||||||
|
</CollectionViewSource.SortDescriptions>
|
||||||
|
</CollectionViewSource>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Token and pull data button -->
|
||||||
|
<materialDesign:Card
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="12,12,0,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Token icon -->
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Grid.Column="0"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="8"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Password" />
|
||||||
|
|
||||||
|
<!-- Token value -->
|
||||||
|
<TextBox
|
||||||
|
x:Name="TokenValueTextBox"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="0,6,6,8"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
materialDesign:HintAssist.Hint="Token"
|
||||||
|
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||||
|
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
||||||
|
BorderThickness="0"
|
||||||
|
FontSize="16"
|
||||||
|
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
|
||||||
|
<!-- Pull data button -->
|
||||||
|
<Button
|
||||||
|
Grid.Column="2"
|
||||||
|
Margin="0,6,6,6"
|
||||||
|
Padding="4"
|
||||||
|
Command="{s:Action PopulateGuildsAndChannels}"
|
||||||
|
IsDefault="True"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||||
|
ToolTip="Pull available guilds and channels (Enter)">
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="ArrowRight" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</materialDesign:Card>
|
||||||
|
|
||||||
|
<!-- Settings button -->
|
||||||
|
<Button
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="6"
|
||||||
|
Padding="4"
|
||||||
|
Command="{s:Action ShowSettings}"
|
||||||
|
Foreground="{DynamicResource MaterialDesignDarkForeground}"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||||
|
ToolTip="Settings">
|
||||||
|
<Button.Resources>
|
||||||
|
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
|
||||||
|
</Button.Resources>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="Settings" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<ProgressBar
|
||||||
|
Grid.Row="1"
|
||||||
|
Background="{DynamicResource MaterialDesignDarkBackground}"
|
||||||
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
|
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<Grid
|
||||||
|
Grid.Row="2"
|
||||||
|
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||||
|
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
||||||
|
<Grid.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
</Style>
|
||||||
|
</Grid.Resources>
|
||||||
|
<!-- Placeholder / usage instructions -->
|
||||||
|
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
|
<TextBlock
|
||||||
|
Margin="32,16"
|
||||||
|
FontSize="14"
|
||||||
|
LineHeight="20">
|
||||||
|
<Run FontSize="18" Text="Please provide authentication token to continue" />
|
||||||
|
<LineBreak />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<!-- User token -->
|
||||||
|
<InlineUIContainer>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Margin="0,0,2,-2"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Account" />
|
||||||
|
</InlineUIContainer>
|
||||||
|
<Run FontSize="16" Text="To get token for your personal account:" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="* Automating user accounts is technically against TOS —" />
|
||||||
|
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="1. Open Discord in your" />
|
||||||
|
<Hyperlink Command="{s:Action OpenDiscord}">
|
||||||
|
<Run Text="web browser" />
|
||||||
|
</Hyperlink>
|
||||||
|
<Run Text="and login" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="2. Press" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
|
||||||
|
<Run Text="to show developer tools" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="3. Navigate to the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Network" />
|
||||||
|
<Run Text="tab" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="4. Press" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Ctrl+R" />
|
||||||
|
<Run Text="to reload" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="5. Search for a request containing" />
|
||||||
|
<Run FontWeight="SemiBold" Text="messages?limit=50" />
|
||||||
|
<Run Text="or similar" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="6. Select the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Headers" />
|
||||||
|
<Run Text=" tab on the right" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="7. Scroll down to the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Request Headers" />
|
||||||
|
<Run Text="section" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="8. Copy the value of the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="authorization" />
|
||||||
|
<Run Text="header" />
|
||||||
|
<LineBreak />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<!-- Bot token -->
|
||||||
|
<InlineUIContainer>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Margin="0,0,2,-2"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Robot" />
|
||||||
|
</InlineUIContainer>
|
||||||
|
<Run FontSize="16" Text="To get token for a bot account:" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="1. Open Discord" />
|
||||||
|
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
|
||||||
|
<Run Text="developer portal" />
|
||||||
|
</Hyperlink>
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="2. Open your application's settings" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="3. Navigate to the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Bot" />
|
||||||
|
<Run Text="section on the left" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="4. Under" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Token" />
|
||||||
|
<Run Text="click" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Copy" />
|
||||||
|
<LineBreak />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
|
||||||
|
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
|
||||||
|
</TextBlock>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Guilds and channels -->
|
||||||
|
<Grid Background="{DynamicResource MaterialDesignCardBackground}" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Guilds -->
|
||||||
|
<Border
|
||||||
|
Grid.Column="0"
|
||||||
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
|
BorderThickness="0,0,1,0">
|
||||||
|
<ListBox
|
||||||
|
ItemsSource="{Binding AvailableGuilds}"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||||
|
SelectedItem="{Binding SelectedGuild}"
|
||||||
|
SelectionMode="Single">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid
|
||||||
|
Margin="-8"
|
||||||
|
Background="Transparent"
|
||||||
|
Cursor="Hand"
|
||||||
|
ToolTip="{Binding Name}">
|
||||||
|
<!-- Guild icon placeholder -->
|
||||||
|
<Ellipse
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="12,4,12,4"
|
||||||
|
Fill="{DynamicResource MaterialDesignDivider}" />
|
||||||
|
|
||||||
|
<!-- Guild icon -->
|
||||||
|
<Ellipse
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="12,4,12,4"
|
||||||
|
Stroke="{DynamicResource MaterialDesignDivider}"
|
||||||
|
StrokeThickness="1">
|
||||||
|
<Ellipse.Fill>
|
||||||
|
<ImageBrush ImageSource="{Binding IconUrl}" />
|
||||||
|
</Ellipse.Fill>
|
||||||
|
</Ellipse>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Channels -->
|
||||||
|
<Border Grid.Column="1">
|
||||||
|
<ListBox
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
SelectionMode="Extended"
|
||||||
|
TextSearch.TextPath="Model.Name"
|
||||||
|
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
|
||||||
|
<b:Interaction.Behaviors>
|
||||||
|
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
|
||||||
|
</b:Interaction.Behaviors>
|
||||||
|
<ListBox.Style>
|
||||||
|
<Style BasedOn="{StaticResource {x:Type ListBox}}" TargetType="{x:Type ListBox}">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsDirectMessageGuildSelected}" Value="True">
|
||||||
|
<Setter Property="ItemsSource" Value="{Binding Source={StaticResource AvailableDirectMessageChannelsViewSource}}" />
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding IsDirectMessageGuildSelected}" Value="False">
|
||||||
|
<Setter Property="ItemsSource" Value="{Binding Source={StaticResource AvailableChannelsViewSource}}" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</ListBox.Style>
|
||||||
|
<ListBox.GroupStyle>
|
||||||
|
<GroupStyle>
|
||||||
|
<GroupStyle.ContainerStyle>
|
||||||
|
<Style TargetType="{x:Type GroupItem}">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
|
||||||
|
<Expander
|
||||||
|
Margin="0"
|
||||||
|
Padding="0"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Header="{Binding Name}"
|
||||||
|
IsExpanded="False">
|
||||||
|
<ItemsPresenter />
|
||||||
|
</Expander>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</GroupStyle.ContainerStyle>
|
||||||
|
</GroupStyle>
|
||||||
|
</ListBox.GroupStyle>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate DataType="{x:Type data:Channel}">
|
||||||
|
<Grid Margin="-8" Background="Transparent">
|
||||||
|
<Grid.InputBindings>
|
||||||
|
<MouseBinding Command="{s:Action ExportChannels}" MouseAction="LeftDoubleClick" />
|
||||||
|
</Grid.InputBindings>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Grid.ToolTip>
|
||||||
|
<TextBlock>
|
||||||
|
<Run Text="Last message sent on:" />
|
||||||
|
<Run FontWeight="SemiBold" Text="{Binding LastMessageId, Converter={x:Static converters:SnowflakeToDateTimeOffsetConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}, TargetNullValue=never}" />
|
||||||
|
</TextBlock>
|
||||||
|
</Grid.ToolTip>
|
||||||
|
|
||||||
|
<!-- Channel icon -->
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,7,0,6"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<materialDesign:PackIcon.Style>
|
||||||
|
<Style TargetType="{x:Type materialDesign:PackIcon}">
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding SupportsVoice}" Value="True">
|
||||||
|
<Setter Property="Kind" Value="VolumeHigh" />
|
||||||
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding SupportsVoice}" Value="False">
|
||||||
|
<Setter Property="Kind" Value="Pound" />
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</materialDesign:PackIcon.Style>
|
||||||
|
</materialDesign:PackIcon>
|
||||||
|
|
||||||
|
<!-- Channel name -->
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="3,8,8,8"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="14"
|
||||||
|
Text="{Binding Name, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<!-- Is selected checkmark -->
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Grid.Column="2"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="8,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Kind="Check"
|
||||||
|
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Export button -->
|
||||||
|
<Button
|
||||||
|
Margin="32,24"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Command="{s:Action ExportChannels}"
|
||||||
|
Style="{DynamicResource MaterialDesignFloatingActionAccentButton}"
|
||||||
|
Visibility="{Binding CanExportChannels, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Kind="Download" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.Views.Components;
|
||||||
|
|
||||||
|
public partial class DashboardView
|
||||||
|
{
|
||||||
|
public DashboardView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
<UserControl
|
<UserControl
|
||||||
Style="{DynamicResource MaterialDesignRoot}"
|
|
||||||
Width="380"
|
|
||||||
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
|
|
||||||
mc:Ignorable="d"
|
|
||||||
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
|
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||||
|
xmlns:globalization="clr-namespace:System.Globalization;assembly=System.Runtime"
|
||||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:s="https://github.com/canton7/Stylet"
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
|
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
Width="380"
|
||||||
|
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
|
||||||
|
Style="{DynamicResource MaterialDesignRoot}"
|
||||||
|
mc:Ignorable="d">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
@@ -30,21 +31,21 @@
|
|||||||
<!-- Guild icon -->
|
<!-- Guild icon -->
|
||||||
<Ellipse
|
<Ellipse
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Height="32"
|
Width="32"
|
||||||
Width="32">
|
Height="32">
|
||||||
<Ellipse.Fill>
|
<Ellipse.Fill>
|
||||||
<ImageBrush ImageSource="{Binding Guild.IconUrl}" />
|
<ImageBrush ImageSource="{Binding Guild.IconUrl}" />
|
||||||
</Ellipse.Fill>
|
</Ellipse.Fill>
|
||||||
</Ellipse>
|
</Ellipse>
|
||||||
|
|
||||||
<!-- Placeholder (for multiple channels) -->
|
<!-- Channel count (for multiple channels) -->
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontSize="19"
|
|
||||||
FontWeight="Light"
|
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="8,0,0,0"
|
Margin="8,0,0,0"
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
|
FontSize="19"
|
||||||
|
FontWeight="Light"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
||||||
<Run Text="{Binding Channels.Count, Mode=OneWay}" />
|
<Run Text="{Binding Channels.Count, Mode=OneWay}" />
|
||||||
<Run Text="channels selected" />
|
<Run Text="channels selected" />
|
||||||
@@ -52,12 +53,12 @@
|
|||||||
|
|
||||||
<!-- Category and channel name (for single channel) -->
|
<!-- Category and channel name (for single channel) -->
|
||||||
<TextBlock
|
<TextBlock
|
||||||
FontSize="19"
|
|
||||||
FontWeight="Light"
|
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="8,0,0,0"
|
Margin="8,0,0,0"
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
|
FontSize="19"
|
||||||
|
FontWeight="Light"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
<Run Text="{Binding Channels[0].Category.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Category.Name, Mode=OneWay}" />
|
<Run Text="{Binding Channels[0].Category.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Category.Name, Mode=OneWay}" />
|
||||||
<Run Text="/" />
|
<Run Text="/" />
|
||||||
@@ -69,24 +70,24 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border
|
<Border
|
||||||
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
|
||||||
BorderThickness="0,1"
|
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Padding="0,8">
|
Padding="0,8"
|
||||||
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
|
BorderThickness="0,1">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<!-- Format -->
|
<!-- Format -->
|
||||||
<ComboBox
|
<ComboBox
|
||||||
|
Margin="16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Format"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
IsReadOnly="True"
|
IsReadOnly="True"
|
||||||
ItemsSource="{Binding AvailableFormats}"
|
ItemsSource="{Binding AvailableFormats}"
|
||||||
Margin="16,8"
|
|
||||||
SelectedItem="{Binding SelectedFormat}"
|
SelectedItem="{Binding SelectedFormat}"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedComboBox}"
|
Style="{DynamicResource MaterialDesignOutlinedComboBox}">
|
||||||
materialDesign:HintAssist.Hint="Format"
|
|
||||||
materialDesign:HintAssist.IsFloating="True">
|
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
|
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}" />
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
@@ -105,66 +106,66 @@
|
|||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
|
||||||
Grid.Column="0"
|
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
|
Grid.Column="0"
|
||||||
Margin="16,8,16,4"
|
Margin="16,8,16,4"
|
||||||
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
|
||||||
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
|
|
||||||
ToolTip="Only include messages sent after this date"
|
|
||||||
materialDesign:HintAssist.Hint="After (date)"
|
materialDesign:HintAssist.Hint="After (date)"
|
||||||
materialDesign:HintAssist.IsFloating="True" />
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
<DatePicker
|
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
|
||||||
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
|
||||||
Grid.Column="1"
|
|
||||||
Grid.Row="0"
|
|
||||||
Margin="16,8,16,4"
|
|
||||||
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
|
|
||||||
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
|
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
|
||||||
ToolTip="Only include messages sent before this date"
|
ToolTip="Only include messages sent after this date" />
|
||||||
|
<DatePicker
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="16,8,16,4"
|
||||||
materialDesign:HintAssist.Hint="Before (date)"
|
materialDesign:HintAssist.Hint="Before (date)"
|
||||||
materialDesign:HintAssist.IsFloating="True" />
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
|
||||||
|
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
|
||||||
|
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
|
||||||
|
ToolTip="Only include messages sent before this date" />
|
||||||
<materialDesign:TimePicker
|
<materialDesign:TimePicker
|
||||||
Grid.Column="0"
|
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,4,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="After (time)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
|
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
|
||||||
IsEnabled="{Binding IsAfterDateSet}"
|
IsEnabled="{Binding IsAfterDateSet}"
|
||||||
Margin="16,4,16,8"
|
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
|
||||||
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
|
||||||
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
||||||
ToolTip="Only include messages sent after this time"
|
ToolTip="Only include messages sent after this time" />
|
||||||
materialDesign:HintAssist.Hint="After (time)"
|
|
||||||
materialDesign:HintAssist.IsFloating="True" />
|
|
||||||
<materialDesign:TimePicker
|
<materialDesign:TimePicker
|
||||||
Grid.Column="1"
|
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="16,4,16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Before (time)"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
|
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
|
||||||
IsEnabled="{Binding IsBeforeDateSet}"
|
IsEnabled="{Binding IsBeforeDateSet}"
|
||||||
Margin="16,4,16,8"
|
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
|
||||||
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
|
||||||
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
||||||
ToolTip="Only include messages sent before this time"
|
ToolTip="Only include messages sent before this time" />
|
||||||
materialDesign:HintAssist.Hint="Before (time)"
|
|
||||||
materialDesign:HintAssist.IsFloating="True" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Partitioning -->
|
<!-- Partitioning -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Partition limit"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
|
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
|
||||||
Text="{Binding PartitionLimitValue}"
|
Text="{Binding PartitionLimitValue}"
|
||||||
ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')"
|
ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')" />
|
||||||
materialDesign:HintAssist.Hint="Partition limit"
|
|
||||||
materialDesign:HintAssist.IsFloating="True" />
|
|
||||||
|
|
||||||
<!-- Filtering -->
|
<!-- Filtering -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
|
materialDesign:HintAssist.Hint="Message filter"
|
||||||
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
|
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
|
||||||
Text="{Binding MessageFilterValue}"
|
Text="{Binding MessageFilterValue}"
|
||||||
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')."
|
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." />
|
||||||
materialDesign:HintAssist.Hint="Message filter"
|
|
||||||
materialDesign:HintAssist.IsFloating="True" />
|
|
||||||
|
|
||||||
<!-- Download media -->
|
<!-- Download media -->
|
||||||
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
|
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
|
||||||
@@ -175,13 +176,13 @@
|
|||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Text="Download media"
|
VerticalAlignment="Center"
|
||||||
VerticalAlignment="Center" />
|
Text="Download media" />
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
IsChecked="{Binding ShouldDownloadMedia}"
|
VerticalAlignment="Center"
|
||||||
VerticalAlignment="Center" />
|
IsChecked="{Binding ShouldDownloadMedia}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -198,8 +199,8 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
Command="{s:Action ToggleAdvancedSection}"
|
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
|
Command="{s:Action ToggleAdvancedSection}"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
ToolTip="Toggle advanced options">
|
ToolTip="Toggle advanced options">
|
||||||
<Button.Style>
|
<Button.Style>
|
||||||
@@ -217,17 +218,17 @@
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
Grid.Column="2"
|
||||||
Command="{s:Action Confirm}"
|
Command="{s:Action Confirm}"
|
||||||
Content="EXPORT"
|
Content="EXPORT"
|
||||||
Grid.Column="2"
|
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
||||||
<Button
|
<Button
|
||||||
|
Grid.Column="3"
|
||||||
|
Margin="8,0,0,0"
|
||||||
Command="{s:Action Close}"
|
Command="{s:Action Close}"
|
||||||
Content="CANCEL"
|
Content="CANCEL"
|
||||||
Grid.Column="3"
|
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
Margin="8,0,0,0"
|
|
||||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user