Compare commits

..

78 Commits

Author SHA1 Message Date
Oleksii Holub 6f47fb6a1c Update version 2022-06-01 19:55:49 +03:00
Oleksii Holub 6150720b1e Update readme 2022-05-31 12:29:31 +03:00
Oleksii Holub dcdeea19ab Update nuget packages 2022-05-29 22:43:42 +03:00
Oleksii Holub ca56d47110 Allow whitespace in sticker names 2022-05-20 20:45:42 +03:00
Oleksii Holub 17b548d679 Add test for last change 2022-05-18 20:19:46 +03:00
Andrew Kolos 20eeaa58e9 Add has:pin filter (#853)
Adds a new `has:*` filter. Specifically, `has:pin`.
The filter filters for messages that are pinned.
2022-05-18 10:11:50 -07:00
Oleksii Holub 6621c10ae0 Satisfy my OCD 2022-04-30 16:04:57 +03:00
Oleksii Holub 75214354ca Update CliFx 2022-04-22 19:48:11 +03:00
Oleksii Holub 84d99e8520 Reorganize assets 2022-04-22 17:19:51 +03:00
Oleksii Holub 4b80eabb73 Update NuGet.config 2022-04-22 17:03:03 +03:00
Oleksii Holub e6830bbd0e Plural artifacts 2022-04-22 02:10:00 +03:00
Oleksii Holub 97c751f171 Simplify workflows 2022-04-22 01:23:48 +03:00
Oleksii Holub dc814e08e7 Update readme 2022-04-22 01:14:35 +03:00
Oleksii Holub 54bf7c204a Consolidate workflows 2022-04-22 01:13:36 +03:00
Oleksii Holub 36c9983c9e Update test logger 2022-04-19 04:29:50 +03:00
Oleksii Holub 361e0489ed Update NuGet packages 2022-04-17 03:36:15 +03:00
Oleksii Holub 77436120a8 Update readme 2022-04-16 18:48:39 +03:00
Oleksii Holub 2d7ab5411f Revert "Use nightly.link for linking CI builds"
This reverts commit 1ce523b252.
2022-04-13 22:40:27 +03:00
Oleksii Holub 1ce523b252 Use nightly.link for linking CI builds 2022-04-13 22:38:41 +03:00
Oleksii Holub bb81cf06ae Rework retry policy
Fixes #832
2022-04-12 23:55:03 +03:00
Oleksii Holub f7f6ac9494 Update version 2022-04-10 21:43:52 +03:00
Oleksii Holub 481958f4d3 Update Readme.md 2022-04-09 21:58:41 +03:00
Oleksii Holub 5e3502cbc5 Update Readme.md 2022-04-09 21:57:34 +03:00
Oleksii Holub 3b03c30865 Update screenshots in readme 2022-04-09 21:04:03 +03:00
Oleksii Holub c9e16a7f74 Spaces in css rules 2022-04-09 20:37:12 +03:00
Oleksii Holub f9b6793d47 Update margins in HTML embeds 2022-04-09 20:30:17 +03:00
Oleksii Holub cfbd267d3a Fix embed styles in HTML 2022-04-09 20:25:13 +03:00
Oleksii Holub 2b6f01bf6f Fix font sizes in HTML template 2022-04-09 20:08:37 +03:00
Oleksii Holub a44d43eefc Use a lock when opening dialogs 2022-04-09 03:17:30 +03:00
Oleksii Holub 0e138bac7d Improve top-level spacing in html template 2022-04-09 03:05:54 +03:00
Oleksii Holub 71876febf8 Use attachment description as alt text in html
Closes #805
2022-04-09 02:52:57 +03:00
Oleksii Holub 489c786c1e Pass token without validation
Fixes #828
2022-04-09 02:46:40 +03:00
Oleksii Holub eb0c61e173 Try to fix remaining test 2022-04-09 02:35:07 +03:00
Oleksii Holub 9341c21963 Add clickable links to usage guide in GUI 2022-04-09 02:31:52 +03:00
Oleksii Holub 704c483074 Update nuget packages 2022-04-09 02:22:32 +03:00
Oleksii Holub 1f0e667085 Fix missing awaits in preamble template 2022-04-09 02:21:49 +03:00
Oleksii Holub 0b72b73d1d Fix more tests 2022-04-09 02:13:58 +03:00
Oleksii Holub 32dd1689d0 Fix tests 2022-04-09 02:07:51 +03:00
Oleksii Holub 2e4d19671a Append channel name for export errors in GUI 2022-04-09 02:01:19 +03:00
Oleksii Holub 5b4d88ca26 Update HTML layout and styles 2022-04-09 01:58:13 +03:00
Chris Helder 65cc3954b6 Use 512x512 user profile pictures (#826) 2022-04-08 00:39:07 +03:00
Oleksii Holub ffe5544f9c Dowload fonts in HTML export when using download media 2022-04-07 20:26:12 +03:00
Oleksii Holub bf1ad07a9c Use coverlet collector 2022-04-06 21:17:47 +00:00
Oleksii Holub 338566c1d6 More cleanup 2022-04-06 19:43:10 +03:00
Oleksii Holub 91ea7a443c More cleanup 2022-04-06 19:12:27 +03:00
Oleksii Holub aea96d5eba Cleanup 2022-04-06 19:00:19 +03:00
Oleksii Holub e29f08264c Refactor portions of RootViewModel into DashboardViewModel 2022-04-04 01:34:46 +03:00
Oleksii Holub ad84ecf6a4 Fix hyperlink style 2022-04-02 01:11:13 +03:00
Oleksii Holub ae57a0ff7c Update readme 2022-04-01 02:27:01 +03:00
Oleksii Holub 1daff4178d Cleanup GUI 2022-04-01 00:10:17 +03:00
Oleksii Holub 3a2b119618 Update Readme.md 2022-03-22 11:02:14 -07:00
Oleksii Holub 5f27426f44 Update Readme.md 2022-03-21 10:24:44 -07:00
Oleksii Holub a893ebda68 Update version 2022-03-07 03:43:47 +02:00
Oleksii Holub 9221966171 Fix binding issue by using an older .NET SDK to build 2022-03-07 03:33:54 +02:00
Oleksii Holub 67325b610a Update version 2022-03-07 03:02:28 +02:00
Oleksii Holub 3186ce90c8 Fix GUI not launching due to .NET bug 2022-03-07 02:46:56 +02:00
Oleksii Holub 168d7c8ec9 Update the war message in CLI 2022-03-07 02:39:05 +02:00
Oleksii Holub daa1b3f6b3 Improve the war message 2022-03-07 01:09:56 +02:00
Oleksii Holub e2780d6666 Update version 2022-03-06 19:41:11 +02:00
Oleksii Holub b996215e36 Add messages about war in Ukraine 2022-03-06 19:24:22 +02:00
Oleksii Holub 08d9af534b Add terms of use 2022-03-05 09:30:41 -08:00
Oleksii Holub bef110a193 Add fuck russia banner to readme 2022-02-23 18:25:23 +02:00
Oleksii Holub 12555e576f Update NuGet packages 2022-02-21 22:47:40 +02:00
Oleksii Holub afb4bc67ae Auto-detect dark mode preference in GUI 2022-02-20 22:29:23 +02:00
Oleksii Holub f80a5fe644 Fix multiline quote rendering in HTML
Fixes #756
2022-02-19 00:49:56 +02:00
Oleksii Holub b7dca87fb9 Add reaction filter
Closes #715
2022-02-19 00:28:38 +02:00
Oleksii Holub 8ecc2940b0 HTML-escape links inside HTML export instead of URL-escaping 2022-02-19 00:26:54 +02:00
Oleksii Holub 407c2bd2ae Don't crash on invalid mentions
Fixes #816
2022-02-19 00:26:53 +02:00
Oleksii Holub 5b563d4f00 Cleanup some code 2022-02-19 00:26:53 +02:00
Tomlacko ecb615ecf1 Fix for extremely long file extensions (#812) 2022-02-17 19:58:47 -08:00
Oleksii Holub 7c212ef893 Update NuGet packages 2022-02-16 14:34:01 +02:00
Oleksii Holub 36b4a45f3a Update Gress 2022-02-16 14:30:26 +02:00
Oleksii Holub 51cc132e5d Display 24-hour time pickers for appropriate cultures in GUI
Related to #808
2022-02-10 17:23:39 +02:00
Oleksii Holub cf83cbd89c Add support for stickers (#802) 2022-02-05 16:26:23 -08:00
Oleksii Holub d51d0d4872 Use snowflakes in more places 2022-02-06 01:15:56 +02:00
Oleksii Holub 2467caac9f Use api v9 2022-02-05 20:19:16 +02:00
Oleksii Holub bd98f4cb6a Refactor StringPart into StringSegment 2022-02-05 04:10:35 +02:00
Oleksii Holub fc7191d74c Fix changelog date for last entry 2022-01-27 18:44:40 +02:00
92 changed files with 2305 additions and 1622 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

-30
View File
@@ -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
-84
View File
@@ -1,84 +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:
dotnet-version: 6.0.x
- 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>"
}
-23
View File
@@ -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
-47
View File
@@ -1,47 +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:
dotnet-version: 6.0.x
- 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/
+55
View File
@@ -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 .
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 -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 -t tyrrrz/discordchatexporter:stable .
- 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 }}
+154
View File
@@ -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

+36 -1
View File
@@ -1,4 +1,39 @@
### v2.32 (15-Dec-2021)
### 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)
- Actually fixed it this time.
### v2.33.1 (07-Mar-2022)
- Fixed an issue where the application silently failed to run if the system didn't have .NET Runtime 6.0.2 installed. If you continue seeing this issue, please uninstall all existing .NET runtimes from your computer and then try running the application again.
### v2.33 (06-Mar-2022)
- Added messages informing about war in Ukraine and available ways to help.
- Added support for rendering stickers in HTML and JSON. Lottie-based stickers currently cannot be displayed in HTML exports (see [#803](https://github.com/Tyrrrz/DiscordChatExporter/issues/803)).
- Added a new `reaction:` message filter which can be used to check if someone reacted to a message with a specific emoji. You can either pass the emoji name (e.g. `reaction:LUL`) or its ID (e.g. `reaction:41771983429993937`).
- [GUI] Added auto-detection for dark mode. If your system is configured to prefer dark mode in applications, DiscordChatExporter will use it by default instead of light mode.
- Fixed an issue which caused the export process to crash when downloading media files with extremely long file extensions. (Thanks [@Tomlacko](https://github.com/Tomlacko))
- Fixed an issue which caused the export process to crash on invalid mentions.
- [GUI] Fixed an issue where the time pickers used to specify export ranges always displayed time in 12-hour format, instead of respecting the system locale.
### v2.32 (27-Jan-2022)
- Token kind (user or bot) is now detected automatically. Removed the button to switch token kind in GUI. Option `-b|--bot` in CLI is now deprecated and does nothing.
- Updated user token extraction guide to reflect the fact that devtools are no longer accessible in the desktop version of Discord client. The recommended workaround is to open Discord in browser. (Thanks [@Dhananjay-JSR](https://github.com/Dhananjay-JSR))
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>2.32</Version>
<Version>2.34.1</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
@@ -1,28 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<CollectCoverage>true</CollectCoverage>
<CoverletOutputFormat>opencover</CoverletOutputFormat>
<NoWarn>$(NoWarn);xUnit1013</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="0.16.1" />
<PackageReference Include="FluentAssertions" Version="6.4.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.0" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="coverlet.msbuild" Version="3.1.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
+2 -3
View File
@@ -3,12 +3,11 @@
This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios.
In order to run these tests locally, you need to join the test server and configure your authentication token.
1. Join the server: https://discord.gg/eRV8Vap5bm
1. [Join the test server](https://discord.gg/eRV8Vap5bm)
2. Locate your Discord authentication token
3. Specify your token using a file or an environment variable:
- **Using a file**: put your token in a new `DiscordToken.secret` file created in this directory
- **Using an environment variable**: set `DISCORD_TOKEN` variable to your token
4. Run the tests: `dotnet test`
> If you want to have a new test case or a scenario added, please let me know in your pull request.
Currently, it's not possible to add them by yourself.
> If you want to add a new test case, please let me know and I will give you the required permissions
@@ -100,6 +100,34 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
.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());
var data = await File.ReadAllTextAsync(filePath);
var document = Json.Parse(data);
// Assert
document
.GetProperty("messages")
.EnumerateArray()
.Select(j => j.GetProperty("content").GetString())
.Should()
.ContainSingle("This is pinned");
}
[Fact]
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
{
@@ -42,7 +42,7 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
Snowflake.Parse("885654862656843786")
);
var imageUrl = message.QuerySelector("img")?.GetAttribute("src");
var imageUrl = message.QuerySelector(".chatlog__attachment img")?.GetAttribute("src");
// Assert
message.Text().Should().Contain("Image attachment");
@@ -20,7 +20,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
);
// Assert
message.Text().Trim().Should().Be("User mention: @Tyrrrz");
message.Text().Trim().Should().Contain("User mention: @Tyrrrz");
message.InnerHtml.Should().Contain("Tyrrrz#5447");
}
@@ -34,7 +34,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
);
// Assert
message.Text().Trim().Should().Be("Text channel mention: #mention-tests");
message.Text().Trim().Should().Contain("Text channel mention: #mention-tests");
}
[Fact]
@@ -47,7 +47,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
);
// Assert
message.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc");
message.Text().Trim().Should().Contain("Voice channel mention: 🔊chaos-vc");
}
[Fact]
@@ -60,6 +60,6 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
);
// Assert
message.Text().Trim().Should().Be("Role mention: @Role 1");
message.Text().Trim().Should().Contain("Role mention: @Role 1");
}
}
@@ -20,8 +20,8 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
);
// Assert
message.Text().Trim().Should().Be("reply to original");
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original");
message.Text().Trim().Should().Contain("reply to original");
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Contain("original");
}
[Fact]
@@ -34,13 +34,13 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
);
// Assert
message.Text().Trim().Should().Be("reply to deleted");
message.Text().Trim().Should().Contain("reply to deleted");
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
.Be("Original message was deleted or could not be loaded.");
.Contain("Original message was deleted or could not be loaded.");
}
[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()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@@ -49,8 +49,8 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
);
// Assert
message.Text().Trim().Should().Be("reply to attachment");
message.Text().Trim().Should().Contain("reply to attachment");
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
.Be("Click to see attachment 🖼️");
.Contain("Click to see attachment");
}
}
@@ -0,0 +1,45 @@
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Fixtures;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
{
[Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670623158943754")
);
var container = message.QuerySelector("[title='rock']");
var sourceUrl = container?.QuerySelector("img")?.GetAttribute("src");
// Assert
container.Should().NotBeNull();
sourceUrl.Should().Be("https://discord.com/stickers/904215665597120572.png");
}
[Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670526517997590")
);
var container = message.QuerySelector("[title='Yikes']");
var sourceUrl = container?.QuerySelector("div[data-source]")?.GetAttribute("data-source");
// Assert
container.Should().NotBeNull();
sourceUrl.Should().Be("https://discord.com/stickers/816087132447178774.json");
}
}
@@ -0,0 +1,54 @@
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Fixtures;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
{
[Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670623158943754")
);
var sticker = message
.GetProperty("stickers")
.EnumerateArray()
.Single();
// Assert
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
sticker.GetProperty("name").GetString().Should().Be("rock");
sticker.GetProperty("format").GetString().Should().Be("PngAnimated");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/904215665597120572.png");
}
[Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.StickerTestCases,
Snowflake.Parse("939670526517997590")
);
var sticker = message
.GetProperty("stickers")
.EnumerateArray()
.Single();
// Assert
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
sticker.GetProperty("name").GetString().Should().Be("Yikes");
sticker.GetProperty("format").GetString().Should().Be("Lottie");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/816087132447178774.json");
}
}
@@ -59,6 +59,6 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
// Assert
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
.Should()
.HaveCount(2);
.HaveCount(8);
}
}
@@ -10,6 +10,8 @@ public static class ChannelIds
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
@@ -14,6 +14,7 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using Gress;
namespace DiscordChatExporter.Cli.Commands.Base;
@@ -78,8 +79,7 @@ public abstract class ExportCommandBase : TokenCommandBase
{
try
{
await progressContext.StartTaskAsync(
$"{channel.Category.Name} / {channel.Name}",
await progressContext.StartTaskAsync($"{channel.Category.Name} / {channel.Name}",
async progress =>
{
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
@@ -98,7 +98,11 @@ public abstract class ExportCommandBase : TokenCommandBase
DateFormat
);
await Exporter.ExportChannelAsync(request, progress, innerCancellationToken);
await Exporter.ExportChannelAsync(
request,
progress.ToPercentageBased(),
innerCancellationToken
);
}
);
}
@@ -162,4 +166,16 @@ public abstract class ExportCommandBase : TokenCommandBase
await ExecuteAsync(console, channels);
}
public override ValueTask ExecuteAsync(IConsole console)
{
// War in Ukraine message
console.Output.WriteLine("========================================================================");
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("========================================================================");
console.Output.WriteLine("");
return default;
}
}
@@ -15,6 +15,8 @@ public class ExportAllCommand : ExportCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
@@ -15,6 +15,9 @@ public class ExportChannelsCommand : ExportCommandBase
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
public override async ValueTask ExecuteAsync(IConsole console) =>
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
await base.ExecuteAsync(console, ChannelIds);
}
}
@@ -13,6 +13,8 @@ public class ExportDirectMessagesCommand : ExportCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels...");
@@ -16,6 +16,8 @@ public class ExportGuildCommand : ExportCommandBase
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels...");
@@ -6,9 +6,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.2.1" />
<PackageReference Include="Spectre.Console" Version="0.43.0" />
<PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="CliFx" Version="2.2.5" />
<PackageReference Include="Spectre.Console" Version="0.44.0" />
<PackageReference Include="Gress" Version="2.0.1" />
</ItemGroup>
<ItemGroup>
@@ -13,6 +13,7 @@ public partial record Attachment(
Snowflake Id,
string Url,
string FileName,
string? Description,
int? Width,
int? Height,
FileSize FileSize) : IHasId
@@ -37,8 +38,9 @@ public partial record Attachment
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
var fileName = json.GetProperty("filename").GetNonNullString();
var description = json.GetPropertyOrNull("description")?.GetNonWhiteSpaceStringOrNull();
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);
}
}
@@ -10,14 +10,14 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/emoji#emoji-object
public partial record Emoji(
// Only present on custom emoji
string? Id,
Snowflake? Id,
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
string Name,
bool IsAnimated,
string ImageUrl)
{
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
public string Code => !string.IsNullOrWhiteSpace(Id)
public string Code => Id is not null
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
}
@@ -32,18 +32,18 @@ public partial record Emoji
.Select(r => r.Value.ToString("x"))
);
private static string GetImageUrl(string 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}.png";
private static string GetImageUrl(string name) =>
$"https://twemoji.maxcdn.com/2/svg/{GetTwemojiName(name)}.svg";
public static string GetImageUrl(string? id, string? name, bool isAnimated)
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{
// Custom emoji
if (!string.IsNullOrWhiteSpace(id))
return GetImageUrl(id, isAnimated);
if (id is not null)
return GetImageUrl(id.Value, isAnimated);
// Standard emoji
if (!string.IsNullOrWhiteSpace(name))
@@ -55,7 +55,7 @@ public partial record Emoji
public static Emoji Parse(JsonElement json)
{
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull();
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
@@ -21,6 +21,7 @@ public record Message(
string Content,
IReadOnlyList<Attachment> Attachments,
IReadOnlyList<Embed> Embeds,
IReadOnlyList<Sticker> Stickers,
IReadOnlyList<Reaction> Reactions,
IReadOnlyList<User> MentionedUsers,
MessageReference? Reference,
@@ -60,6 +61,10 @@ public record Message(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
Array.Empty<Embed>();
var stickers =
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??
Array.Empty<Sticker>();
var reactions =
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ??
Array.Empty<Reaction>();
@@ -79,6 +84,7 @@ public record Message(
content,
attachments,
embeds,
stickers,
reactions,
mentionedUsers,
messageReference,
@@ -0,0 +1,26 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/sticker#sticker-resource
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
{
private static string GetSourceUrl(Snowflake id, StickerFormat format)
{
var extension = format == StickerFormat.Lottie ? "json" : "png";
return $"https://discord.com/stickers/{id}.{extension}";
}
public static Sticker Parse(JsonElement json)
{
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonNullString();
var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
var sourceUrl = GetSourceUrl(id, format);
return new Sticker(id, name, format, sourceUrl);
}
}
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Core.Discord.Data;
public enum StickerFormat
{
Png = 1,
PngAnimated = 2,
Lottie = 3
}
@@ -30,7 +30,7 @@ public partial record User
? "gif"
: "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)
@@ -47,4 +47,4 @@ public partial record User
return new User(id, isBot, discriminator, name, avatarUrl);
}
}
}
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -12,6 +11,7 @@ using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress;
using JsonExtensions.Http;
using JsonExtensions.Reading;
@@ -20,7 +20,7 @@ namespace DiscordChatExporter.Core.Discord;
public class DiscordClient
{
private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
private TokenKind _tokenKind = TokenKind.Unknown;
@@ -33,9 +33,12 @@ public class DiscordClient
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
request.Headers.Authorization = isBot
? new AuthenticationHeaderValue("Bot", _token)
: new AuthenticationHeaderValue(_token);
// Don't validate because token can have invalid characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation(
"Authorization",
isBot ? $"Bot {_token}" : _token
);
return await Http.Client.SendAsync(
request,
@@ -48,7 +51,7 @@ public class DiscordClient
string url,
CancellationToken cancellationToken = default)
{
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
{
if (_tokenKind == TokenKind.User)
return await GetResponseAsync(url, false, innerCancellationToken);
@@ -273,7 +276,7 @@ public class DiscordClient
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<double>? progress = null,
IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Get the last message in the specified range.
@@ -322,16 +325,13 @@ public class DiscordClient
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
if (totalDuration > TimeSpan.Zero)
{
progress.Report(exportedDuration / totalDuration);
}
// Avoid division by zero if all messages have the exact same timestamp
// (which may be the case if there's only one message in the channel)
else
{
progress.Report(1);
}
progress.Report(Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp
// (which may be the case if there's only one message in the channel)
totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration
: 1
));
}
yield return message;
@@ -5,8 +5,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" />
<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="Superpower" Version="3.0.0" />
</ItemGroup>
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress;
namespace DiscordChatExporter.Core.Exporting;
@@ -19,7 +20,7 @@ public class ChannelExporter
public async ValueTask ExportChannelAsync(
ExportRequest request,
IProgress<double>? progress = null,
IProgress<Percentage>? progress = null,
CancellationToken cancellationToken = default)
{
// Build context
@@ -1,5 +1,4 @@
using System;
using System.IO;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
@@ -60,8 +59,8 @@ public partial record ExportRequest
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0",
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%%" => "%",
_ => m.Value
})
@@ -93,7 +92,7 @@ public partial record ExportRequest
// Date range
if (after is not null || before is not null)
{
buffer.Append(" (");
buffer.Append(' ').Append('(');
// Both 'after' and 'before' are set
if (after is not null && before is not null)
@@ -111,11 +110,11 @@ public partial record ExportRequest
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
}
buffer.Append(")");
buffer.Append(')');
}
// File extension
buffer.Append($".{format.GetFileExtension()}");
buffer.Append('.').Append(format.GetFileExtension());
return PathEx.EscapeFileName(buffer.ToString());
}
@@ -19,6 +19,7 @@ internal class HasMessageFilter : MessageFilter
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
MessageContentMatchKind.Pin => message.IsPinned,
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
};
}
@@ -7,5 +7,6 @@ internal enum MessageContentMatchKind
File,
Video,
Image,
Sound
Sound,
Pin
}
@@ -44,6 +44,12 @@ internal static class FilterGrammar
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
.Named("mentions:<value>");
private static readonly TextParser<MessageFilter> ReactionFilter = Span
.EqualToIgnoreCase("reaction:")
.IgnoreThen(String)
.Select(v => (MessageFilter) new ReactionMessageFilter(v))
.Named("reaction:<value>");
private static readonly TextParser<MessageFilter> HasFilter = Span
.EqualToIgnoreCase("has:")
.IgnoreThen(Parse.OneOf(
@@ -52,7 +58,8 @@ internal static class FilterGrammar
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound))
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
))
.Select(k => (MessageFilter) new HasMessageFilter(k))
.Named("has:<value>");
@@ -72,6 +79,7 @@ internal static class FilterGrammar
GroupedFilter,
FromFilter,
MentionsFilter,
ReactionFilter,
HasFilter,
ContainsFilter
);
@@ -0,0 +1,18 @@
using System;
using System.Linq;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ReactionMessageFilter : MessageFilter
{
private readonly string _value;
public ReactionMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => message.Reactions.Any(r =>
string.Equals(_value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) ||
string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
);
}
@@ -35,45 +35,42 @@ internal partial class MediaDownloader
var filePath = Path.Combine(_workingDirPath, fileName);
// Reuse existing files if we're allowed to
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 () =>
if (!_reuseMedia || !File.Exists(filePath))
{
// Download the file
using var response = await Http.Client.GetAsync(url, cancellationToken);
await using (var output = File.Create(filePath))
{
await response.Content.CopyToAsync(output, cancellationToken);
}
Directory.CreateDirectory(_workingDirPath);
// Try to set the file date according to the last-modified header
try
await Http.ResiliencePolicy.ExecuteAsync(async () =>
{
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
? date
: (DateTimeOffset?) null
);
// Download the file
using var response = await Http.Client.GetAsync(url, cancellationToken);
await using (var output = File.Create(filePath))
await response.Content.CopyToAsync(output, cancellationToken);
if (lastModified is not null)
// Try to set the file date according to the last-modified header
try
{
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
? 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
{
// This can apparently fail for some reason.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
// Updating file dates is not a critical task, so we'll just
// ignore exceptions thrown here.
}
});
catch
{
// This can apparently fail for some reason.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
// Updating file dates is not a critical task, so we'll just
// ignore exceptions thrown here.
}
});
}
return _pathCache[url] = filePath;
}
@@ -104,6 +101,14 @@ internal partial class MediaDownloader
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
var fileExtension = Path.GetExtension(fileName);
// Probably not a file extension, just a dot in a long file name
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708
if (fileExtension.Length > 41)
{
fileNameWithoutExtension = fileName;
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
);
}
}
@@ -1,127 +1,146 @@
@using System
@using System.Linq
@using System.Threading.Tasks
@using DiscordChatExporter.Core.Discord.Data
@using DiscordChatExporter.Core.Exporting.Writers.Html;
@using DiscordChatExporter.Core.Utils.Extensions
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
@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 FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
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
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
var referencedUserMember = firstMessage.ReferencedMessage is not null
? Model.ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id)
: null;
var userNick = Model.MessageGroup.Author.IsBot
? Model.MessageGroup.Author.Name
: userMember?.Nick ?? Model.MessageGroup.Author.Name;
var referencedUserMember = Model.MessageGroup.ReferencedMessage is not null
? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id)
var referencedUserColor = firstMessage.ReferencedMessage is not null
? Model.ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id)
: null;
var referencedUserColor = Model.MessageGroup.ReferencedMessage is not null
? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id)
: null;
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
var referencedUserNick = firstMessage.ReferencedMessage is not null
? firstMessage.ReferencedMessage.Author.IsBot
? firstMessage.ReferencedMessage.Author.Name
: referencedUserMember?.Nick ?? firstMessage.ReferencedMessage.Author.Name
: null;
}
<div class="chatlog__message-group">
@{/* Referenced message */}
@if (Model.MessageGroup.Reference is not null)
{
<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>
@for (var i = 0; i < Model.Messages.Count; i++)
{
var isFirst = i == 0;
var message = Model.Messages[i];
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp is not null)
{
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(Model.MessageGroup.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
else
{
<span class="chatlog__reference-unknown">
Original message was deleted or could not be loaded.
</span>
}
</div>
}
@{/* Avatar */}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar" loading="lazy">
</div>
<div class="chatlog__messages">
@{/* Author name */}
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
@{/* Bot tag */}
@if (Model.MessageGroup.Author.IsBot)
{
<span class="chatlog__bot-tag">BOT</span>
}
@{/* Message timestamp */}
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
@{/* Messages in a group */}
@foreach (var message in Model.MessageGroup.Messages)
{
<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 id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message">
@{/* Left side */}
<div class="chatlog__message-aside">
@if (isFirst)
{
<div class="chatlog__content">
<div class="markdown">
@{/* Message content */}
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
// Reference symbol
if (message.Reference is not null)
{
<div class="chatlog__reference-symbol"></div>
}
@{/* Edit timestamp */}
@if (message.EditedTimestamp is not null)
// Avatar
<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))
{
@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))
}
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>
}
// 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">@FormatDate(message.Timestamp)</span>
</div>
}
@{/* Content */}
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
{
<div class="chatlog__content chatlog__markdown">
@{/* Text */}
<span class="chatlog__markdown-preserve">@Raw(FormatMarkdown(message.Content))</span>
@{/* Edited timestamp */}
@if (message.EditedTimestamp is not null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
@@ -139,26 +158,26 @@
@if (attachment.IsImage)
{
<a href="@await ResolveUrlAsync(attachment.Url)">
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")" loading="lazy">
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a>
}
else if (attachment.IsVideo)
{
<video class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
</video>
}
else if (attachment.IsAudio)
{
<audio class="chatlog__attachment-media" controls>
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio>
}
else
{
<div class="chatlog__attachment-generic">
<svg class="chatlog__attachment-generic-icon">
<use href="#icon-attachment" />
<use href="#attachment-icon"/>
</svg>
<div class="chatlog__attachment-generic-name">
<a href="@await ResolveUrlAsync(attachment.Url)">
@@ -201,7 +220,7 @@
@{/* Color pill */}
@if (embed.Color is not null)
{
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
<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
{
@@ -214,7 +233,7 @@
@{/* Embed author */}
@if (embed.Author is not null)
{
<div class="chatlog__embed-author">
<div class="chatlog__embed-author-container">
@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'">
@@ -222,16 +241,16 @@
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
<span class="chatlog__embed-author-name">
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
}
else
{
@embed.Author.Name
}
</span>
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
<div class="chatlog__embed-author">@embed.Author.Name</div>
</a>
}
else
{
<div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</div>
}
@@ -243,12 +262,12 @@
@if (!string.IsNullOrWhiteSpace(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">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a>
}
else
{
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
}
</div>
}
@@ -269,7 +288,7 @@
@{/* Color pill */}
@if (embed.Color is not null)
{
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
<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
{
@@ -282,7 +301,7 @@
@{/* Embed author */}
@if (embed.Author is not null)
{
<div class="chatlog__embed-author">
<div class="chatlog__embed-author-container">
@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'">
@@ -290,16 +309,16 @@
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
<span class="chatlog__embed-author-name">
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
}
else
{
@embed.Author.Name
}
</span>
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
<div class="chatlog__embed-author">@embed.Author.Name</div>
</a>
}
else
{
<div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</div>
}
@@ -311,12 +330,12 @@
@if (!string.IsNullOrWhiteSpace(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">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a>
}
else
{
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
}
</div>
}
@@ -325,7 +344,7 @@
@if (!string.IsNullOrWhiteSpace(embed.Description))
{
<div class="chatlog__embed-description">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Description))</div>
</div>
}
@@ -339,14 +358,14 @@
@if (!string.IsNullOrWhiteSpace(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">@Raw(FormatEmbedMarkdown(field.Name))</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(field.Value))</div>
</div>
}
</div>
@@ -411,6 +430,21 @@
}
}
@{/* Stickers */}
@foreach (var sticker in message.Stickers)
{
<div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
{
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker">
}
else if (sticker.Format == StickerFormat.Lottie)
{
<div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div>
}
</div>
}
@{/* Message reactions */}
@if (message.Reactions.Any())
{
@@ -418,13 +452,14 @@
@foreach (var reaction in message.Reactions)
{
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" 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>
</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;
@@ -6,12 +8,12 @@ internal class MessageGroupTemplateContext
{
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;
MessageGroup = messageGroup;
Messages = messages;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
@@ -11,10 +11,13 @@
? darkVariant
: 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);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
}
@@ -29,39 +32,41 @@
@{/* Styling */}
<style>
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(300)));
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
font-weight: 300;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(400)));
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
font-weight: 400;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(500)));
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
font-weight: 500;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(600)));
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
font-weight: 600;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(700)));
font-family: Whitney;
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
font-weight: 700;
}
body {
html, body {
margin: 0;
padding: 0;
background-color: @Themed("#36393e", "#ffffff");
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-weight: @Themed("400", "500");
}
@@ -77,115 +82,15 @@
img {
object-fit: contain;
}
.markdown {
max-width: 100%;
line-height: 1.3;
overflow-wrap: break-word;
}
.preserve-whitespace {
white-space: pre-wrap;
}
.spoiler-text {
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
border-radius: 3px;
}
.spoiler-text--hidden {
cursor: pointer;
background-color: @Themed("#202225", "#b9bbbe");
color: rgba(0, 0, 0, 0);
}
.spoiler-text--hidden:hover {
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
}
.spoiler-text--hidden::selection {
color: rgba(0, 0, 0, 0);
}
.quote {
display: flex;
margin: 0.05em 0;
}
.quote::before {
content: "";
margin-right: 0.5em;
border: 2px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 3px;
}
.pre {
background-color: @Themed("#2f3136", "#f9f9f9");
font-family: "Consolas", "Courier New", Courier, monospace;
}
.pre--multiline {
margin-top: 0.25em;
padding: 0.5em;
border: 2px solid @Themed("#282b30", "#f3f3f3");
border-radius: 5px;
color: @Themed("#b9bbbe", "#657b83");
}
@{/* Override Highlight.js styles with a higher specificity selector */}
.pre--multiline.hljs {
background-color: @Themed("#2f3136", "#f9f9f9");
color: @Themed("#b9bbbe", "#657b83");
}
.pre--inline {
padding: 2px;
border-radius: 3px;
font-size: 0.85em;
}
.mention {
border-radius: 3px;
padding: 0 2px;
color: @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;
image-rendering: high-quality;
image-rendering: -webkit-optimize-contrast;
}
.preamble {
display: grid;
margin: 0 0.3em 0.6em 0.3em;
max-width: 100%;
grid-template-columns: auto 1fr;
max-width: 100%;
padding: 1rem;
}
.preamble__guild-icon-container {
@@ -199,59 +104,114 @@
.preamble__entries-container {
grid-column: 2;
margin-left: 0.6em;
margin-left: 1rem;
}
.preamble__entry {
font-size: 1.4em;
margin-bottom: 0.15rem;
color: @Themed("#ffffff", "#2f3136");
font-size: 1.4rem;
}
.preamble__entry--small {
font-size: 1em;
font-size: 1rem;
}
.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 {
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;
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;
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 {
grid-column: 1;
margin: 8px 4px 4px 18px;
height: 10px;
margin: 6px 4px 4px 36px;
border-left: 2px solid @Themed("#4f545c", "#c7ccd1");
border-top: 2px solid @Themed("#4f545c", "#c7ccd1");
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 {
display: flex;
grid-column: 2;
margin-bottom: 0.25em;
font-size: 0.875em;
margin-bottom: 0.15rem;
align-items: center;
color: @Themed("#b5b6b8", "#5f5f60");
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-items: center;
color: @Themed("#b5b6b8", "#5f5f60");
}
.chatlog__reference-avatar {
border-radius: 50%;
width: 16px;
height: 16px;
margin-right: 0.25em;
margin-right: 0.25rem;
border-radius: 50%;
}
.chatlog__reference-name {
margin-right: 0.3em;
.chatlog__reference-author {
margin-right: 0.3rem;
font-weight: 600;
}
@@ -260,13 +220,8 @@
text-overflow: ellipsis;
}
.chatlog__reference-content a:hover {
text-decoration: none;
}
.chatlog__reference-link {
cursor: pointer;
color: @Themed("#b5b6b8", "#5f5f60");
}
.chatlog__reference-link * {
@@ -274,82 +229,75 @@
pointer-events: none;
}
.chatlog__reference-link .hljs {
display: inline;
}
.chatlog__reference-link:hover {
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__reference-link:hover *:not(.spoiler-text) {
color: @Themed("#ffffff", "#2f3136");
.chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
color: inherit;
}
.chatlog__reference-edited-timestamp {
margin-left: 0.25em;
font-size: 0.8em;
unicode-bidi: bidi-override;
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__author-avatar-container {
grid-column: 1;
width: 40px;
height: 40px;
margin-right: 16px;
}
.chatlog__author-avatar {
border-radius: 50%;
width: 40px;
height: 40px;
}
.chatlog__messages {
grid-column: 2;
min-width: 50%;
margin-left: 0.25rem;
color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
direction: ltr;
unicode-bidi: bidi-override;
}
.chatlog__author-name {
.chatlog__header {
margin-bottom: 0.1rem;
}
.chatlog__author {
font-weight: @Themed("500", "600");
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 {
margin-left: 0.3em;
font-size: 0.75em;
margin-left: 0.3rem;
color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
direction: ltr;
unicode-bidi: bidi-override;
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__message {
padding: 0.1em 0.3em;
margin: 0 -0.3em;
background-color: transparent;
transition: background-color 1s ease;
}
.chatlog__message--highlighted {
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
}
.chatlog__message--pinned {
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
}
.chatlog__content {
font-size: 0.95em;
padding-right: 1rem;
font-size: 0.95rem;
word-wrap: break-word;
}
.chatlog__edited-timestamp {
margin-left: 0.15em;
font-size: 0.8em;
margin-left: 0.15rem;
color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
}
.chatlog__attachment {
display: inline-block;
position: relative;
margin-top: 0.3em;
width: fit-content;
margin-top: 0.3rem;
border-radius: 3px;
overflow: hidden;
}
@@ -365,18 +313,18 @@
.chatlog__attachment-spoiler-caption {
display: none;
z-index: 999;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
padding: 0.4em 0.8em;
background-color: rgba(0, 0, 0, 0.9);
z-index: 999;
padding: 0.4rem 0.8rem;
border-radius: 20px;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.9);
color: #dcddde;
font-size: 0.9em;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.05em;
letter-spacing: 0.05rem;
}
.chatlog__attachment--hidden .chatlog__attachment-spoiler-caption {
@@ -388,9 +336,9 @@
}
.chatlog__attachment-media {
vertical-align: top;
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
@@ -399,13 +347,13 @@
}
.chatlog__attachment-generic {
width: 100%;
max-width: 520px;
width: 100%;
height: 40px;
padding: 10px;
background-color: @Themed("#2f3136", "#f2f3f5");
border: 1px solid @Themed("#292b2f", "#ebedef");
border-radius: 3px;
background-color: @Themed("#2f3136", "#f2f3f5");
overflow: hidden;
}
@@ -431,19 +379,15 @@
text-overflow: ellipsis;
}
.chatlog__edited-timestamp {
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
}
.chatlog__embed {
display: flex;
margin-top: 0.3em;
margin-top: 0.3rem;
max-width: 520px;
}
.chatlog__embed-color-pill {
flex-shrink: 0;
width: 0.25em;
width: 0.25rem;
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
}
@@ -455,11 +399,11 @@
.chatlog__embed-content-container {
display: flex;
flex-direction: column;
padding: 0.5em 0.6em;
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
padding: 0.5rem 0.6rem;
border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)");
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
}
.chatlog__embed-content {
@@ -471,85 +415,85 @@
flex: 1;
}
.chatlog__embed-author {
.chatlog__embed-author-container {
display: flex;
margin-bottom: 0.3em;
margin-bottom: 0.5rem;
align-items: center;
}
.chatlog__embed-author-icon {
margin-right: 0.5em;
width: 20px;
height: 20px;
margin-right: 0.5rem;
border-radius: 50%;
}
.chatlog__embed-author-name {
font-size: 0.875em;
.chatlog__embed-author {
color: @Themed("#ffffff", "#4f545c");
font-size: 0.875rem;
font-weight: 600;
direction: ltr;
unicode-bidi: bidi-override;
color: @Themed("#ffffff", "#4f545c")
}
.chatlog__embed-author-name-link {
.chatlog__embed-author-link {
color: @Themed("#ffffff", "#4f545c");
}
.chatlog__embed-title {
margin-bottom: 0.2em;
font-size: 0.875em;
font-weight: 600;
margin-bottom: 0.5rem;
color: @Themed("#ffffff", "#4f545c");
font-size: 0.875rem;
font-weight: 600;
}
.chatlog__embed-description {
font-weight: 500;
font-size: 0.85em;
color: @Themed("#dcddde", "#2e3338");
font-weight: 500;
font-size: 0.85rem;
}
.chatlog__embed-fields {
display: flex;
flex-wrap: wrap;
gap: 0 0.5em;
gap: 0 0.5rem;
}
.chatlog__embed-field {
flex: 0;
min-width: 100%;
max-width: 506px;
padding-top: 0.6em;
font-size: 0.875em;
padding-top: 0.6rem;
font-size: 0.875rem;
}
.chatlog__embed-field--inline {
flex: 1;
flex-basis: auto;
min-width: 150px;
min-width: 50px;
}
.chatlog__embed-field-name {
margin-bottom: 0.2em;
font-weight: 600;
margin-bottom: 0.2rem;
color: @Themed("#ffffff", "#36393e");
font-weight: 600;
}
.chatlog__embed-field-value {
font-weight: 500;
color: @Themed("#dcddde", "#2e3338");
font-weight: 500;
}
.chatlog__embed-thumbnail {
flex: 0;
margin-left: 1.2em;
max-width: 80px;
max-height: 80px;
margin-left: 1.2rem;
border-radius: 3px;
}
.chatlog__embed-image-container {
margin-top: 0.6em;
margin-top: 0.6rem;
}
.chatlog__embed-image {
@@ -559,28 +503,28 @@
}
.chatlog__embed-footer {
margin-top: 0.6em;
margin-top: 0.6rem;
color: @Themed("#dcddde", "#2e3338");
}
.chatlog__embed-footer-icon {
margin-right: 0.2em;
width: 20px;
height: 20px;
margin-right: 0.2rem;
border-radius: 50%;
vertical-align: middle;
}
.chatlog__embed-footer-text {
vertical-align: middle;
font-size: 0.75em;
font-size: 0.75rem;
font-weight: 500;
}
.chatlog__embed-plainimage {
vertical-align: top;
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
@@ -589,7 +533,7 @@
}
.chatlog__embed-youtube-container {
margin-top: 0.6em;
margin-top: 0.6rem;
}
.chatlog__embed-youtube {
@@ -597,57 +541,148 @@
border-radius: 3px;
}
.chatlog__sticker {
width: 180px;
height: 180px;
}
.chatlog__sticker--media {
max-width: 100%;
max-height: 100%;
}
.chatlog__reactions {
display: flex;
}
.chatlog__reaction {
display: flex;
align-items: center;
margin: 0.35em 0.1em 0.1em 0;
padding: .125rem .375rem;
background-color: @Themed("#2f3136", "#f2f3f5");
margin: 0.35rem 0.1rem 0.1rem 0;
padding: 0.125rem 0.375rem;
border: 1px solid transparent;
border-radius: 8px;
border-color: transparent;
border-width: 1px;
border-style: solid;
background-color: @Themed("#2f3136", "#f2f3f5");
align-items: center;
}
.chatlog__reaction:hover {
border-color: @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)");
border-width: 1px;
border-style: solid;
border: 1px solid @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)");
background-color: @Themed("transparent", "white");
}
.chatlog__reaction-count {
min-width: 9px;
margin-left: 0.35em;
font-size: 0.875em;
margin-left: 0.35rem;
color: @Themed("#b9bbbe", "#4f5660");
font-size: 0.875rem;
}
.chatlog__reaction:hover .chatlog__reaction-count {
color: @Themed("#dcddde", "#2e3338");
}
.chatlog__bot-tag {
position: relative;
top: -.1em;
margin-left: 0.3em;
padding: 0.05em 0.3em;
border-radius: 3px;
.chatlog__markdown {
max-width: 100%;
line-height: 1.3;
background-color: #5865F2;
color: #ffffff;
font-size: 0.625em;
overflow-wrap: break-word;
}
.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;
}
.chatlog__markdown-pre--multiline {
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: inherit;
color: inherit;
}
.chatlog__markdown-pre--inline {
padding: 2px;
border-radius: 3px;
font-size: 0.85rem;
}
.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;
}
.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 {
margin: 1.4em 0.3em 0.6em 0.3em;
padding: 1em;
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
padding: 1.25rem;
}
.postamble__entry {
@@ -656,54 +691,79 @@
</style>
@{/* Syntax highlighting */}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<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="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
document.querySelectorAll('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e));
});
</script>
@{/* Lottie animation support */}
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
const imageDataUrl = e.getAttribute('data-source');
const anim = lottie.loadAnimation({
container: e,
renderer: 'svg',
loop: true,
autoplay: true,
path: imageDataUrl
});
anim.addEventListener('data_failed', () =>
e.innerHTML = '<strong>[Sticker cannot be rendered]</strong>'
);
});
});
</script>
@{/* Scripts */}
<script>
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({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
window.setTimeout(function() {
element.classList.remove('chatlog__message-container--highlighted');
}, 2000);
}
function showSpoiler(event, element) {
if (element && element.classList.contains('spoiler-text--hidden')) {
event.preventDefault();
element.classList.remove('spoiler-text--hidden');
}
if (element && element.classList.contains('chatlog__attachment--hidden')) {
if (!element)
return;
if (element.classList.contains('chatlog__attachment--hidden')) {
event.preventDefault();
element.classList.remove('chatlog__attachment--hidden');
}
if (element.classList.contains('chatlog__markdown-spoiler--hidden')) {
event.preventDefault();
element.classList.remove('chatlog__markdown-spoiler--hidden');
}
}
</script>
@{/* Icons */}
<svg style="display: none">
<symbol id="icon-attachment" viewBox="0 0 720 960">
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" />
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" />
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" />
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" />
<symbol 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="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/>
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/>
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/>
</symbol>
</svg>
</head>
@@ -725,18 +785,18 @@
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
{
<div class="preamble__entry preamble__entry--small">
@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())}")
}
else if (Model.ExportContext.Request.After is not null)
{
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
}
else if (Model.ExportContext.Request.Before is not null)
{
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
}
@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())}")
}
else if (Model.ExportContext.Request.After is not null)
{
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
}
else if (Model.ExportContext.Request.Before is not null)
{
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
}
</div>
}
</div>
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
@@ -13,7 +14,7 @@ internal class HtmlMessageWriter : MessageWriter
private readonly TextWriter _writer;
private readonly string _themeName;
private readonly List<Message> _messageGroupBuffer = new();
private readonly List<Message> _messageGroup = new();
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
: base(stream, context)
@@ -22,6 +23,23 @@ internal class HtmlMessageWriter : MessageWriter
_themeName = themeName;
}
private bool CanJoinGroup(Message message)
{
var lastMessage = _messageGroup.LastOrDefault();
if (lastMessage is null)
return true;
return
// 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;
}
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
{
var templateContext = new PreambleTemplateContext(Context, _themeName);
@@ -34,10 +52,10 @@ internal class HtmlMessageWriter : MessageWriter
}
private async ValueTask WriteMessageGroupAsync(
MessageGroup messageGroup,
IReadOnlyList<Message> messages,
CancellationToken cancellationToken = default)
{
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
var templateContext = new MessageGroupTemplateContext(Context, messages);
// We are not writing directly to output because Razor
// does not actually do asynchronous writes to stream.
@@ -52,31 +70,26 @@ internal class HtmlMessageWriter : MessageWriter
{
await base.WriteMessageAsync(message, cancellationToken);
// If message group is empty or the given message can be grouped, buffer the given message
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
// If the message can be grouped, buffer it for now
if (CanJoinGroup( message))
{
_messageGroupBuffer.Add(message);
_messageGroup.Add(message);
}
// Otherwise, flush the group and render messages
else
{
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken);
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
_messageGroupBuffer.Clear();
_messageGroupBuffer.Add(message);
_messageGroup.Clear();
_messageGroup.Add(message);
}
}
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
{
// Flush current message group
if (_messageGroupBuffer.Any())
{
await WriteMessageGroupAsync(
MessageGroup.Join(_messageGroupBuffer),
cancellationToken
);
}
if (_messageGroup.Any())
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
@@ -161,6 +161,21 @@ internal class JsonMessageWriter : MessageWriter
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteStickerAsync(
Sticker sticker,
CancellationToken cancellationToken = default)
{
_writer.WriteStartObject();
_writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString("sourceUrl", await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteReactionAsync(
Reaction reaction,
CancellationToken cancellationToken = default)
@@ -169,7 +184,7 @@ internal class JsonMessageWriter : MessageWriter
// Emoji
_writer.WriteStartObject("emoji");
_writer.WriteString("id", reaction.Emoji.Id);
_writer.WriteString("id", reaction.Emoji.Id.ToString());
_writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
@@ -276,6 +291,14 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteEndArray();
// Stickers
_writer.WriteStartArray("stickers");
foreach (var sticker in message.Stickers)
await WriteStickerAsync(sticker, cancellationToken);
_writer.WriteEndArray();
// Reactions
_writer.WriteStartArray("reactions");
@@ -3,7 +3,6 @@ using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
@@ -34,14 +33,37 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
{
var (tagOpen, tagClose) = formatting.Kind switch
{
FormattingKind.Bold => ("<strong>", "</strong>"),
FormattingKind.Italic => ("<em>", "</em>"),
FormattingKind.Underline => ("<u>", "</u>"),
FormattingKind.Strikethrough => ("<s>", "</s>"),
FormattingKind.Bold => (
"<strong>",
"</strong>"
),
FormattingKind.Italic => (
"<em>",
"</em>"
),
FormattingKind.Underline => (
"<u>",
"</u>"
),
FormattingKind.Strikethrough => (
"<s>",
"</s>"
),
FormattingKind.Spoiler => (
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
FormattingKind.Quote => ("<div class=\"quote\">", "</div>"),
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
"<span class=\"chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden\" onclick=\"showSpoiler(event, this)\">",
"</span>"
),
FormattingKind.Quote => (
"<div class=\"chatlog__markdown-quote\"><div class=\"chatlog__markdown-quote-border\"></div><div class=\"chatlog__markdown-quote-content\">",
"</div></div>"
),
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
};
_buffer.Append(tagOpen);
@@ -54,7 +76,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
{
_buffer
.Append("<span class=\"pre pre--inline\">")
.Append("<span class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
.Append(HtmlEncode(inlineCodeBlock.Code))
.Append("</span>");
@@ -68,7 +90,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: "nohighlight";
_buffer
.Append($"<div class=\"pre pre--multiline {highlightCssClass}\">")
.Append($"<div class=\"chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightCssClass}\">")
.Append(HtmlEncode(multiLineCodeBlock.Code))
.Append("</div>");
@@ -85,8 +107,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_buffer.Append(
!string.IsNullOrWhiteSpace(linkedMessageId)
? $"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
: $"<a href=\"{Uri.EscapeUriString(link.Url)}\">"
? $"<a href=\"{HtmlEncode(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
: $"<a href=\"{HtmlEncode(link.Url)}\">"
);
var result = base.VisitLink(link);
@@ -98,59 +120,66 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
{
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "emoji--large" : "";
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
_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);
}
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Kind == MentionKind.Meta)
if (mention.Kind == MentionKind.Everyone)
{
_buffer
.Append("<span class=\"mention\">")
.Append("@").Append(HtmlEncode(mention.Id))
.Append("<span class=\"chatlog__markdown-mention\">")
.Append("@everyone")
.Append("</span>");
}
else if (mention.Kind == MentionKind.Here)
{
_buffer
.Append("<span class=\"chatlog__markdown-mention\">")
.Append("@here")
.Append("</span>");
}
else if (mention.Kind == MentionKind.User)
{
var member = mentionId?.Pipe(_context.TryGetMember);
var member = mention.TargetId?.Pipe(_context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown";
_buffer
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
.Append("@").Append(HtmlEncode(nick))
.Append($"<span class=\"chatlog__markdown-mention\" title=\"{HtmlEncode(fullName)}\">")
.Append('@').Append(HtmlEncode(nick))
.Append("</span>");
}
else if (mention.Kind == MentionKind.Channel)
{
var channel = mentionId?.Pipe(_context.TryGetChannel);
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel";
_buffer
.Append("<span class=\"mention\">")
.Append("<span class=\"chatlog__markdown-mention\">")
.Append(symbol).Append(HtmlEncode(name))
.Append("</span>");
}
else if (mention.Kind == MentionKind.Role)
{
var role = mentionId?.Pipe(_context.TryGetRole);
var role = mention.TargetId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
var color = role?.Color;
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
.Append($"<span class=\"mention\" style=\"{style}\">")
.Append("@").Append(HtmlEncode(name))
.Append($"<span class=\"chatlog__markdown-mention\" style=\"{style}\">")
.Append('@').Append(HtmlEncode(name))
.Append("</span>");
}
@@ -169,7 +198,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: "Invalid date";
_buffer
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
.Append($"<span class=\"chatlog__markdown-timestamp\" title=\"{HtmlEncode(longDateString)}\">")
.Append(HtmlEncode(dateString))
.Append("</span>");
@@ -1,5 +1,4 @@
using System.Text;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
@@ -36,21 +35,24 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override MarkdownNode VisitMention(MentionNode mention)
{
var mentionId = Snowflake.TryParse(mention.Id);
if (mention.Kind == MentionKind.Meta)
if (mention.Kind == MentionKind.Everyone)
{
_buffer.Append($"@{mention.Id}");
_buffer.Append("@everyone");
}
else if (mention.Kind == MentionKind.Here)
{
_buffer.Append("@here");
}
else if (mention.Kind == MentionKind.User)
{
var member = mentionId?.Pipe(_context.TryGetMember);
var member = mention.TargetId?.Pipe(_context.TryGetMember);
var name = member?.User.Name ?? "Unknown";
_buffer.Append($"@{name}");
}
else if (mention.Kind == MentionKind.Channel)
{
var channel = mentionId?.Pipe(_context.TryGetChannel);
var channel =mention.TargetId?.Pipe(_context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{name}");
@@ -61,7 +63,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
}
else if (mention.Kind == MentionKind.Role)
{
var role = mentionId?.Pipe(_context.TryGetRole);
var role = mention.TargetId?.Pipe(_context.TryGetRole);
var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{name}");
@@ -98,6 +98,25 @@ internal class PlainTextMessageWriter : MessageWriter
}
}
private async ValueTask WriteStickersAsync(
IReadOnlyList<Sticker> stickers,
CancellationToken cancellationToken = default)
{
if (!stickers.Any())
return;
await _writer.WriteLineAsync("{Stickers}");
foreach (var sticker in stickers)
{
cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
}
await _writer.WriteLineAsync();
}
private async ValueTask WriteReactionsAsync(
IReadOnlyList<Reaction> reactions,
CancellationToken cancellationToken = default)
@@ -156,9 +175,10 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync();
// Attachments, embeds, reactions
// Attachments, embeds, reactions, etc.
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
await WriteEmbedsAsync(message.Embeds, cancellationToken);
await WriteStickersAsync(message.Stickers, cancellationToken);
await WriteReactionsAsync(message.Reactions, cancellationToken);
await _writer.WriteLineAsync();
@@ -1,21 +1,22 @@
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown;
internal record EmojiNode(
// Only present on custom emoji
string? Id,
Snowflake? Id,
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
string Name,
bool IsAnimated) : MarkdownNode
{
public bool IsCustomEmoji => Id is not null;
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
public string Code => !string.IsNullOrWhiteSpace(Id)
public string Code => IsCustomEmoji
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);
public EmojiNode(string name)
: this(null, name, false)
{
@@ -2,7 +2,8 @@
internal enum MentionKind
{
Meta,
Everyone,
Here,
User,
Channel,
Role
@@ -1,3 +1,6 @@
namespace DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Discord;
internal record MentionNode(string Id, MentionKind Kind) : MarkdownNode;
namespace DiscordChatExporter.Core.Markdown;
// Null ID means it's a meta mention or an invalid mention
internal record MentionNode(Snowflake? TargetId, MentionKind Kind) : MarkdownNode;
@@ -16,7 +16,7 @@ internal class AggregateMatcher<T> : IMatcher<T>
{
}
public ParsedMatch<T>? TryMatch(StringPart stringPart)
public ParsedMatch<T>? TryMatch(StringSegment segment)
{
ParsedMatch<T>? earliestMatch = null;
@@ -24,19 +24,19 @@ internal class AggregateMatcher<T> : IMatcher<T>
foreach (var matcher in _matchers)
{
// Try to match
var match = matcher.TryMatch(stringPart);
var match = matcher.TryMatch(segment);
// If there's no match - continue
if (match is null)
continue;
// If this match is earlier than previous earliest - replace
if (earliestMatch is null || match.StringPart.StartIndex < earliestMatch.StringPart.StartIndex)
if (earliestMatch is null || match.Segment.StartIndex < earliestMatch.Segment.StartIndex)
earliestMatch = match;
// If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that
if (earliestMatch.StringPart.StartIndex == stringPart.StartIndex)
if (earliestMatch.Segment.StartIndex == segment.StartIndex)
break;
}
@@ -5,44 +5,57 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
internal interface IMatcher<T>
{
ParsedMatch<T>? TryMatch(StringPart stringPart);
ParsedMatch<T>? TryMatch(StringSegment segment);
}
internal static class MatcherExtensions
{
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher,
StringPart stringPart, Func<StringPart, T> transformFallback)
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
this IMatcher<T> matcher,
StringSegment segment,
Func<StringSegment, T> transformFallback)
{
// Loop through segments divided by individual matches
var currentIndex = stringPart.StartIndex;
while (currentIndex < stringPart.EndIndex)
var currentIndex = segment.StartIndex;
while (currentIndex < segment.EndIndex)
{
// Find a match within this segment
var match = matcher.TryMatch(stringPart.Slice(currentIndex, stringPart.EndIndex - currentIndex));
var match = matcher.TryMatch(
segment.Relocate(
currentIndex,
segment.EndIndex - currentIndex
)
);
// If there's no match - break
if (match is null)
break;
// If this match doesn't start immediately at current index - transform and yield fallback first
if (match.StringPart.StartIndex > currentIndex)
// If this match doesn't start immediately at the current position - transform and yield fallback first
if (match.Segment.StartIndex > currentIndex)
{
var fallbackPart = stringPart.Slice(currentIndex, match.StringPart.StartIndex - currentIndex);
yield return new ParsedMatch<T>(fallbackPart, transformFallback(fallbackPart));
var fallbackSegment = segment.Relocate(
currentIndex,
match.Segment.StartIndex - currentIndex
);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
}
// Yield match
yield return match;
// Shift current index to the end of the match
currentIndex = match.StringPart.StartIndex + match.StringPart.Length;
currentIndex = match.Segment.StartIndex + match.Segment.Length;
}
// If EOL wasn't reached - transform and yield remaining part as fallback
if (currentIndex < stringPart.EndIndex)
// If EOL hasn't been reached - transform and yield remaining part as fallback
if (currentIndex < segment.EndIndex)
{
var fallbackPart = stringPart.Slice(currentIndex);
yield return new ParsedMatch<T>(fallbackPart, transformFallback(fallbackPart));
var fallbackSegment = segment.Relocate(
currentIndex,
segment.EndIndex - currentIndex
);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown.Parsing;
@@ -23,7 +24,7 @@ internal static partial class MarkdownParser
// Capture any character until the earliest double asterisk not followed by an asterisk
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Bold, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
@@ -31,61 +32,62 @@ internal static partial class MarkdownParser
// Closing asterisk must not be preceded by whitespace
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest triple asterisk not followed by an asterisk
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattingNodeMatcher))
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher))
);
// Capture any character except underscore until an underscore
// Closing underscore must not be followed by a word character
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest double underscore not followed by an underscore
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Underline, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest triple underscore not followed by an underscore
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Italic,
Parse(p.Slice(m.Groups[1]), UnderlineFormattingNodeMatcher))
(s, m) => new FormattingNode(FormattingKind.Italic,
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher))
);
// Capture any character until the earliest double tilde
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the earliest double pipe
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Spoiler, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
);
// Capture any character until the end of the line
// Opening 'greater than' character must be followed by whitespace
// Text content is optional
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("^>\\s(.+\n?)", DefaultRegexOptions),
(p, m) => new FormattingNode(FormattingKind.Quote, Parse(p.Slice(m.Groups[1])))
new Regex("^>\\s(.*\n?)", DefaultRegexOptions),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
// Repeatedly capture any character until the end of the line
// This one is tricky as it ends up producing multiple separate captures which need to be joined
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions),
new Regex("(?:^>\\s(.*\n?)){2,}", DefaultRegexOptions),
(_, m) =>
{
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
@@ -97,7 +99,7 @@ internal static partial class MarkdownParser
// Opening 'greater than' characters must be followed by whitespace
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(p, m) => new FormattingNode(FormattingKind.Quote, Parse(p.Slice(m.Groups[1])))
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
/* Code blocks */
@@ -123,31 +125,31 @@ internal static partial class MarkdownParser
// Capture @everyone
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
"@everyone",
_ => new MentionNode("everyone", MentionKind.Meta)
_ => new MentionNode(null, MentionKind.Everyone)
);
// Capture @here
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
"@here",
_ => new MentionNode("here", MentionKind.Meta)
_ => new MentionNode(null, MentionKind.Here)
);
// Capture <@123456> or <@!123456>
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.User)
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
);
// Capture <#123456>
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<#!?(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Channel)
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
);
// Capture <@&123456>
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@&(\\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Role)
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
);
/* Emoji */
@@ -177,7 +179,11 @@ internal static partial class MarkdownParser
// Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
(_, m) => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))
(_, m) => new EmojiNode(
Snowflake.TryParse(m.Groups[3].Value),
m.Groups[2].Value,
!string.IsNullOrWhiteSpace(m.Groups[1].Value)
)
);
/* Links */
@@ -185,7 +191,7 @@ internal static partial class MarkdownParser
// Capture [title](link)
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
(p, m) => new LinkNode(m.Groups[2].Value, Parse(p.Slice(m.Groups[1])))
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
);
// Capture any non-whitespace character after http:// or https://
@@ -207,7 +213,7 @@ internal static partial class MarkdownParser
// This escapes it from matching for formatting
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
@"¯\_(ツ)_/¯",
p => new TextNode(p.ToString())
s => new TextNode(s.ToString())
);
// Capture some specific emoji that don't get rendered
@@ -323,24 +329,24 @@ internal static partial class MarkdownParser
UnixTimestampNodeMatcher
);
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart, IMatcher<MarkdownNode> matcher) =>
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) =>
matcher
.MatchAll(stringPart, p => new TextNode(p.ToString()))
.MatchAll(segment, s => new TextNode(s.ToString()))
.Select(r => r.Value)
.ToArray();
}
internal static partial class MarkdownParser
{
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart) =>
Parse(stringPart, AggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
Parse(segment, AggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringPart stringPart) =>
Parse(stringPart, MinimalAggregateNodeMatcher);
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
Parse(segment, MinimalAggregateNodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string input) =>
Parse(new StringPart(input));
Parse(new StringSegment(input));
public static IReadOnlyList<MarkdownNode> ParseMinimal(string input) =>
ParseMinimal(new StringPart(input));
ParseMinimal(new StringSegment(input));
}
@@ -2,13 +2,13 @@
internal class ParsedMatch<T>
{
public StringPart StringPart { get; }
public StringSegment Segment { get; }
public T Value { get; }
public ParsedMatch(StringPart stringPart, T value)
public ParsedMatch(StringSegment segment, T value)
{
StringPart = stringPart;
Segment = segment;
Value = value;
}
}
@@ -6,17 +6,17 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class RegexMatcher<T> : IMatcher<T>
{
private readonly Regex _regex;
private readonly Func<StringPart, Match, T?> _transform;
private readonly Func<StringSegment, Match, T?> _transform;
public RegexMatcher(Regex regex, Func<StringPart, Match, T?> transform)
public RegexMatcher(Regex regex, Func<StringSegment, Match, T?> transform)
{
_regex = regex;
_transform = transform;
}
public ParsedMatch<T>? TryMatch(StringPart stringPart)
public ParsedMatch<T>? TryMatch(StringSegment segment)
{
var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length);
var match = _regex.Match(segment.Source, segment.StartIndex, segment.Length);
if (!match.Success)
return null;
@@ -25,14 +25,14 @@ internal class RegexMatcher<T> : IMatcher<T>
// Which is super weird because regex.Match(string, int) takes the whole input in context.
// So in order to properly account for ^/$ regex tokens, we need to make sure that
// the expression also matches on the bigger part of the input.
if (!_regex.IsMatch(stringPart.Target[..stringPart.EndIndex], stringPart.StartIndex))
if (!_regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex))
return null;
var stringPartMatch = stringPart.Slice(match.Index, match.Length);
var value = _transform(stringPartMatch, match);
var segmentMatch = segment.Relocate(match);
var value = _transform(segmentMatch, match);
return value is not null
? new ParsedMatch<T>(stringPartMatch, value)
? new ParsedMatch<T>(segmentMatch, value)
: null;
}
}
@@ -6,31 +6,31 @@ internal class StringMatcher<T> : IMatcher<T>
{
private readonly string _needle;
private readonly StringComparison _comparison;
private readonly Func<StringPart, T?> _transform;
private readonly Func<StringSegment, T?> _transform;
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T?> transform)
public StringMatcher(string needle, StringComparison comparison, Func<StringSegment, T?> transform)
{
_needle = needle;
_comparison = comparison;
_transform = transform;
}
public StringMatcher(string needle, Func<StringPart, T> transform)
public StringMatcher(string needle, Func<StringSegment, T> transform)
: this(needle, StringComparison.Ordinal, transform)
{
}
public ParsedMatch<T>? TryMatch(StringPart stringPart)
public ParsedMatch<T>? TryMatch(StringSegment segment)
{
var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
var index = segment.Source.IndexOf(_needle, segment.StartIndex, segment.Length, _comparison);
if (index < 0)
return null;
var stringPartMatch = stringPart.Slice(index, _needle.Length);
var value = _transform(stringPartMatch);
var segmentMatch = segment.Relocate(index, _needle.Length);
var value = _transform(segmentMatch);
return value is not null
? new ParsedMatch<T>(stringPartMatch, value)
? new ParsedMatch<T>(segmentMatch, value)
: null;
}
}
@@ -1,21 +0,0 @@
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal readonly record struct StringPart(string Target, int StartIndex, int Length)
{
public int EndIndex => StartIndex + Length;
public StringPart(string target)
: this(target, 0, target.Length)
{
}
public StringPart Slice(int newStartIndex, int newLength) => new(Target, newStartIndex, newLength);
public StringPart Slice(int newStartIndex) => Slice(newStartIndex, EndIndex - newStartIndex);
public StringPart Slice(Capture capture) => Slice(capture.Index, capture.Length);
public override string ToString() => Target.Substring(StartIndex, Length);
}
@@ -0,0 +1,19 @@
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal readonly record struct StringSegment(string Source, int StartIndex, int Length)
{
public int EndIndex => StartIndex + Length;
public StringSegment(string target)
: this(target, 0, target.Length)
{
}
public StringSegment Relocate(int newStartIndex, int newLength) => new(Source, newStartIndex, newLength);
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
public override string ToString() => Source.Substring(StartIndex, Length);
}
@@ -2,5 +2,5 @@
namespace DiscordChatExporter.Core.Markdown;
// Null means invalid date
// Null date means invalid timestamp
internal record UnixTimestampNode(DateTimeOffset? Date) : MarkdownNode;
@@ -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));
}
+21 -29
View File
@@ -1,9 +1,8 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Net.Sockets;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Utils.Extensions;
using Polly;
@@ -14,13 +13,26 @@ public static class Http
{
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
.Handle<IOException>()
.Or<HttpRequestException>()
.OrResult<HttpResponseMessage>(m => m.StatusCode == HttpStatusCode.TooManyRequests)
.OrResult(m => m.StatusCode == HttpStatusCode.RequestTimeout)
.OrResult(m => m.StatusCode >= HttpStatusCode.InternalServerError)
.Handle<Exception>(IsRetryableException)
.WaitAndRetryAsync(4, i => TimeSpan.FromSeconds(Math.Pow(2, i) + 1));
public static IAsyncPolicy<HttpResponseMessage> ResponseResiliencePolicy { get; } =
Policy
.Handle<Exception>(IsRetryableException)
.OrResult<HttpResponseMessage>(m => IsRetryableStatusCode(m.StatusCode))
.WaitAndRetryAsync(
8,
(i, result, _) =>
@@ -43,24 +55,4 @@ public static class Http
},
(_, _, _, _) => 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));
}
+27
View File
@@ -47,6 +47,29 @@
<Setter Property="Minimum" Value="0" />
</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 MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
@@ -90,6 +113,10 @@
</Style.Triggers>
</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) -->
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
<Setter Property="Template">
@@ -9,11 +9,16 @@ namespace DiscordChatExporter.Gui.Behaviors;
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
{
public static readonly DependencyProperty SelectedItemsProperty =
DependencyProperty.Register(nameof(SelectedItems), typeof(IList),
typeof(MultiSelectionListBoxBehavior<T>),
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemsChanged));
public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
nameof(SelectedItems),
typeof(IList),
typeof(MultiSelectionListBoxBehavior<T>),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemsChanged
)
);
private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
@@ -9,19 +9,13 @@ public class DateTimeOffsetToDateTimeConverter : IValueConverter
{
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is DateTimeOffset dateTimeOffsetValue)
return dateTimeOffsetValue.DateTime;
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is DateTimeOffset dateTimeOffsetValue
? dateTimeOffsetValue.DateTime
: default(DateTime?);
return default(DateTime?);
}
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is DateTime dateTimeValue)
return new DateTimeOffset(dateTimeValue);
return default(DateTimeOffset?);
}
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
value is DateTime dateTimeValue
? new DateTimeOffset(dateTimeValue)
: default(DateTimeOffset?);
}
@@ -10,13 +10,10 @@ public class ExportFormatToStringConverter : IValueConverter
{
public static ExportFormatToStringConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is ExportFormat exportFormatValue)
return exportFormatValue.GetDisplayName();
return default(string?);
}
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is ExportFormat exportFormatValue
? exportFormatValue.GetDisplayName()
: default;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
@@ -9,19 +9,9 @@ public class InverseBoolConverter : IValueConverter
{
public static InverseBoolConverter Instance { get; } = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
return !boolValue;
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is false;
return default(bool);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool boolValue)
return !boolValue;
return default(bool);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
value is false;
}
@@ -9,19 +9,13 @@ public class TimeSpanToDateTimeConverter : IValueConverter
{
public static TimeSpanToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is TimeSpan timeSpanValue)
return DateTime.Today.Add(timeSpanValue);
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is TimeSpan timeSpanValue
? DateTime.Today.Add(timeSpanValue)
: default(DateTime?);
return default(DateTime?);
}
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is DateTime dateTimeValue)
return dateTimeValue.TimeOfDay;
return default(TimeSpan?);
}
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
value is DateTime dateTimeValue
? dateTimeValue.TimeOfDay
: default(TimeSpan?);
}
@@ -13,16 +13,16 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="MaterialDesignColors" Version="2.0.4" />
<PackageReference Include="MaterialDesignThemes" Version="4.3.0" />
<PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="MaterialDesignColors" Version="2.0.6" />
<PackageReference Include="MaterialDesignThemes" Version="4.5.0" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.2" />
<PackageReference Include="Stylet" Version="1.3.6" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.0.3" PrivateAssets="all" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.1" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.2.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
@@ -1,13 +1,14 @@
using DiscordChatExporter.Core.Exporting;
using Microsoft.Win32;
using Tyrrrz.Settings;
namespace DiscordChatExporter.Gui.Services;
public class SettingsService : SettingsManager
public partial class SettingsService : SettingsManager
{
public bool IsAutoUpdateEnabled { get; set; } = true;
public bool IsDarkModeEnabled { get; set; }
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
public bool IsTokenPersisted { get; set; } = true;
@@ -35,4 +36,21 @@ public class SettingsService : SettingsManager
}
public bool ShouldSerializeLastToken() => IsTokenPersisted;
}
public partial class SettingsService
{
private static bool IsDarkModeEnabledByDefault()
{
try
{
return Registry.CurrentUser.OpenSubKey(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", false
)?.GetValue("AppsUseLightTheme") is 0;
}
catch
{
return false;
}
}
}
@@ -0,0 +1,10 @@
using System.Globalization;
namespace DiscordChatExporter.Gui.Utils;
internal static class Internationalization
{
public static bool Is24Hours =>
string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator) &&
string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator);
}
+3 -1
View File
@@ -11,6 +11,8 @@ internal static class ProcessEx
UseShellExecute = true
};
using (Process.Start(startInfo)) {}
using (Process.Start(startInfo))
{
}
}
}
@@ -0,0 +1,236 @@
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 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())
{
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)
{
_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 bool IsSingleChannel => Channels is null || Channels.Count == 1;
public bool IsSingleChannel => Channels?.Count == 1;
public string? OutputPath { get; set; }
public IReadOnlyList<ExportFormat> AvailableFormats =>
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
public IReadOnlyList<ExportFormat> AvailableFormats { get; } = Enum.GetValues<ExportFormat>();
public ExportFormat SelectedFormat { get; set; }
@@ -75,7 +74,7 @@ public class ExportSetupViewModel : DialogScreen
MessageFilterValue = _settingsService.LastMessageFilterValue;
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.
IsAdvancedSectionDisplayed =
After != default ||
@@ -96,12 +95,11 @@ public class ExportSetupViewModel : DialogScreen
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
// If single channel - prompt file path
if (Channels is not null && IsSingleChannel)
if (IsSingleChannel)
{
var channel = Channels.Single();
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
Guild!,
channel,
Channels!.Single(),
SelectedFormat,
After?.Pipe(Snowflake.FromDate),
Before?.Pipe(Snowflake.FromDate)
@@ -7,20 +7,41 @@ public class MessageBoxViewModel : DialogScreen
public string? Title { 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 MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory,
string title,
string message)
string title, string message,
string? okButtonText, string? cancelButtonText)
{
var viewModel = factory.CreateMessageBoxViewModel();
viewModel.Title = title;
viewModel.Message = message;
viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText);
viewModel.OkButtonText = okButtonText;
viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText);
viewModel.CancelButtonText = cancelButtonText;
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.IO;
using System.Threading;
using System.Threading.Tasks;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
@@ -8,9 +9,10 @@ using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework;
public class DialogManager
public class DialogManager : IDisposable
{
private readonly IViewManager _viewManager;
private readonly SemaphoreSlim _dialogLock = new(1, 1);
public DialogManager(IViewManager viewManager)
{
@@ -21,20 +23,26 @@ public class DialogManager
{
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();
dialogScreen.Closed -= OnScreenClosed;
}
dialogScreen.Closed += OnScreenClosed;
}
await DialogHost.Show(view, OnDialogOpened);
return dialogScreen.DialogResult;
await _dialogLock.WaitAsync();
try
{
await DialogHost.Show(view, OnDialogOpened);
return dialogScreen.DialogResult;
}
finally
{
_dialogLock.Release();
}
}
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
@@ -59,4 +67,9 @@ public class DialogManager
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;
// Used to instantiate new view models while making use of dependency injection
public interface IViewModelFactory
{
DashboardViewModel CreateDashboardViewModel();
ExportSetupViewModel CreateExportSetupViewModel();
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,56 +1,30 @@
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.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Messages;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Gress;
using MaterialDesignThemes.Wpf;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels;
public class RootViewModel : Screen
public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
{
private readonly IViewModelFactory _viewModelFactory;
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
private readonly UpdateService _updateService;
private DiscordClient? _discord;
public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
public IProgressManager ProgressManager { get; } = new ProgressManager();
public bool IsBusy { get; private set; }
public bool IsProgressIndeterminate { get; private set; }
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 IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
? GuildChannelMap?[SelectedGuild]
: null;
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
public DashboardViewModel Dashboard { get; }
public RootViewModel(
IViewModelFactory viewModelFactory,
IEventAggregator eventAggregator,
DialogManager dialogManager,
SettingsService settingsService,
UpdateService updateService)
@@ -59,23 +33,31 @@ public class RootViewModel : Screen
_dialogManager = dialogManager;
_settingsService = settingsService;
_updateService = updateService;
eventAggregator.Subscribe(this);
Dashboard = _viewModelFactory.CreateDashboardViewModel();
DisplayName = $"{App.Name} v{App.VersionString}";
// Update busy state when progress manager changes
ProgressManager.Bind(o => o.IsActive, (_, _) =>
IsBusy = ProgressManager.IsActive
);
ProgressManager.Bind(o => o.IsActive, (_, _) =>
IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress is <= 0 or >= 1
);
ProgressManager.Bind(o => o.Progress, (_, _) =>
IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress is <= 0 or >= 1
);
}
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()
{
try
@@ -102,18 +84,19 @@ public class RootViewModel : Screen
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();
_settingsService.Load();
if (_settingsService.LastToken is not null)
{
Token = _settingsService.LastToken;
}
if (_settingsService.IsDarkModeEnabled)
{
App.SetDarkTheme();
@@ -122,8 +105,6 @@ public class RootViewModel : Screen
{
App.SetLightTheme();
}
await CheckForUpdatesAsync();
}
protected override void OnClose()
@@ -134,133 +115,8 @@ public class RootViewModel : Screen
_updateService.FinalizeUpdate(false);
}
public async void ShowSettings()
{
var dialog = _viewModelFactory.CreateSettingsViewModel();
await _dialogManager.ShowDialogAsync(dialog);
}
public void Handle(NotificationMessage message) =>
Notifications.Enqueue(message.Text);
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
public bool CanPopulateGuildsAndChannels =>
!IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PopulateGuildsAndChannels()
{
using var operation = ProgressManager.CreateOperation();
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);
}
}
public bool CanExportChannels =>
!IsBusy &&
_discord is not null &&
SelectedGuild is not null &&
SelectedChannels is not null &&
SelectedChannels.Any();
public async void ExportChannels()
{
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 operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
var successfulExportCount = 0;
await Parallel.ForEachAsync(
dialog.Channels.Zip(operations),
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
},
async (tuple, cancellationToken) =>
{
var (channel, operation) = 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, operation, cancellationToken);
Interlocked.Increment(ref successfulExportCount);
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
Notifications.Enqueue(ex.Message.TrimEnd('.'));
}
finally
{
operation.Dispose();
}
}
);
// 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);
}
}
public void Dispose() => Notifications.Dispose();
}
@@ -0,0 +1,368 @@
<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: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>
<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="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using your personal account" />
<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. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+M" />
<Run Text="to toggle device toolbar" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. On the left, expand" />
<Run FontWeight="SemiBold" Text="Local Storage" />
<Run Text="and select" />
<Run FontWeight="SemiBold" Text="https://discord.com" />
<LineBreak />
<Run Text="6. Type" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="into the" />
<Run FontWeight="SemiBold" Text="Filter" />
<Run Text="box" />
<LineBreak />
<Run Text="7. If the token key does not appear, press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="key" />
<LineBreak />
<Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using 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"
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
SelectionMode="Extended"
TextSearch.TextPath="Model.Name"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<b:Interaction.Behaviors>
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</b:Interaction.Behaviors>
<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>
<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>
<!-- Channel icon -->
<materialDesign:PackIcon
Grid.Column="0"
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
<!-- 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();
}
}
@@ -8,6 +8,7 @@
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
@@ -36,7 +37,7 @@
</Ellipse.Fill>
</Ellipse>
<!-- Placeholder (for multiple channels) -->
<!-- Channel count (for multiple channels) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
@@ -129,6 +130,7 @@
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
@@ -139,6 +141,7 @@
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
@@ -6,7 +6,8 @@
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
MinWidth="500"
xmlns:system="clr-namespace:System;assembly=System.Runtime"
Width="500"
d:DataContext="{d:DesignInstance Type=dialogs:MessageBoxViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
@@ -41,15 +42,32 @@
</ScrollViewer>
</Border>
<!-- Close -->
<Button
<UniformGrid
Grid.Row="2"
Margin="16"
HorizontalAlignment="Stretch"
Command="{s:Action Close}"
Content="CLOSE"
IsCancel="True"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
HorizontalAlignment="Right"
Columns="{Binding ButtonsCount}">
<!-- OK -->
<Button
Command="{s:Action Close}"
Content="{Binding OkButtonText}"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}"
Visibility="{Binding IsOkButtonVisible, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Button.CommandParameter>
<system:Boolean>True</system:Boolean>
</Button.CommandParameter>
</Button>
<!-- Cancel -->
<Button
Margin="8,0,0,0"
HorizontalAlignment="Stretch"
Command="{s:Action Close}"
Content="{Binding CancelButtonText}"
IsCancel="True"
Style="{DynamicResource MaterialDesignOutlinedButton}"
Visibility="{Binding IsCancelButtonVisible, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
</UniformGrid>
</Grid>
</UserControl>
@@ -6,7 +6,7 @@
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
Width="310"
Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
+7 -349
View File
@@ -2,11 +2,7 @@
x:Class="DiscordChatExporter.Gui.Views.RootView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
@@ -16,359 +12,21 @@
MinWidth="325"
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
Background="{DynamicResource MaterialDesignPaper}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
Icon="/DiscordChatExporter;component/favicon.ico"
Style="{DynamicResource MaterialDesignRoot}"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Window.TaskbarItemInfo>
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding Dashboard.Progress.Current.Fraction}" />
</Window.TaskbarItemInfo>
<Window.Resources>
<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>
</Window.Resources>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<materialDesign:DialogHost
Loaded="{s:Action OnViewFullyLoaded}"
SnackbarMessageQueue="{Binding Notifications}"
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<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 ProgressManager.Progress, 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">
<Run FontSize="18" Text="Please provide authentication token to continue" />
<LineBreak />
<LineBreak />
<!-- User token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate using your personal account" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<Run FontWeight="SemiBold" Text="web browser" />
<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. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+M" />
<Run Text="to toggle device toolbar" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Application" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. On the left, expand" />
<Run FontWeight="SemiBold" Text="Local Storage" />
<Run Text="and select" />
<Run FontWeight="SemiBold" Text="https://discord.com" />
<LineBreak />
<Run Text="6. Type" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="into the" />
<Run FontWeight="SemiBold" Text="Filter" />
<Run Text="box" />
<LineBreak />
<Run Text="7. If the token key does not appear, press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="8. Copy the value of the" />
<Run FontWeight="SemiBold" Text="token" />
<Run Text="key" />
<LineBreak />
<Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="1,0,0,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run FontSize="16" Text="Authenticate as a bot" />
<LineBreak />
<Run Text="1. Open Discord developer portal" />
<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"
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
SelectionMode="Extended"
TextSearch.TextPath="Model.Name"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<i:Interaction.Behaviors>
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</i:Interaction.Behaviors>
<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>
<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>
<!-- Channel icon -->
<materialDesign:PackIcon
Grid.Column="0"
Margin="16,7,0,6"
VerticalAlignment="Center"
Kind="Pound" />
<!-- 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>
<!-- Notifications snackbar -->
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid>
<ContentControl s:View.Model="{Binding Dashboard}" />
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid>
</materialDesign:DialogHost>
</Window>
+5 -2
View File
@@ -2,6 +2,9 @@
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
<config>
<add key="defaultPushSource" value="https://api.nuget.org/v3/index.json" />
</config>
</configuration>
+27 -44
View File
@@ -1,20 +1,33 @@
# DiscordChatExporter
[![Build](https://github.com/Tyrrrz/DiscordChatExporter/workflows/CI/badge.svg?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://codecov.io/gh/Tyrrrz/DiscordChatExporter/branch/master/graph/badge.svg)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
[![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://vshymanskyy.github.io/StandWithUkraine)
[![Build](https://img.shields.io/github/workflow/status/Tyrrrz/DiscordChatExporter/main/master)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm)
[![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate)
[![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate)
[![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848)
**Project status: active**. [What does it mean?](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
> 🟢 **Project status**: active<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
**DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file.
It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
**If you have questions or issues, please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)**.
> If you have questions or issues, **please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)**.
💬 **If you want to chat, join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
> 💬 If you want to chat, **join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/why-so-political.md)</sup>
By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements:
- You **condemn Russia and its military aggression against Ukraine**
- You **recognize that Russia is an occupant that unlawfully invaded a sovereign state**
- You **support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas**
- You **reject false narratives perpetuated by Russian state propaganda**
To learn more about the war and how you can help, [click here](https://tyrrrz.me). Glory to Ukraine! 🇺🇦
## Download
@@ -35,7 +48,7 @@ The following table lists all available download options:
<td>
<ul>
<li>🟢 <b><a href="https://github.com/Tyrrrz/DiscordChatExporter/releases/latest">Stable release</a></b> (<code>DiscordChatExporter.zip</code>)</li>
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/CI.yml">CI build</a> (<code>DiscordChatExporter.zip</code>)</li>
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.zip</code>)</li>
</ul>
</td>
<td>
@@ -49,7 +62,7 @@ The following table lists all available download options:
<td>
<ul>
<li>🟢 <b><a href="https://github.com/Tyrrrz/DiscordChatExporter/releases/latest">Stable release</a></b> (<code>DiscordChatExporter.CLI.zip</code>) ⚙️</li>
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/CI.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>) ⚙️</li>
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>) ⚙️</li>
<li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li>
<li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>) 🦄</li>
</ul>
@@ -76,43 +89,13 @@ The following table lists all available download options:
- Graphical user interface (Windows)
- Command line interface (Windows, Linux, macOS)
- Support for both user and bot tokens
- Support for Discord's dialect of markdown
- Support for Discord's message filter syntax
- Support for attachments, embeds, emoji, and other rich media features
- Authentication via both user and bot tokens
- Multiple output formats: HTML (dark/light), TXT, CSV, JSON
- File partitioning, date ranges, and other export options
- Exports messages on the fly without buffering in-memory
- Support for markdown, attachments, embeds, emoji, and other rich media features
- File partitioning, date ranges, message filtering, and other export options
- Self-contained exports that don't require internet
## Screenshots
![channel list](.screenshots/list.png)
![rendered output](.screenshots/output.png)
## Building the project locally
Prerequisites:
- [.NET 6.0 SDK](https://dotnet.microsoft.com/download/dotnet/6.0)
- _(Recommended)_ C#/.NET IDE, such as [JetBrains Rider](https://www.jetbrains.com/rider), [VS Code](https://code.visualstudio.com/docs/languages/csharp), or [Visual Studio](https://visualstudio.microsoft.com).
To build the entire solution run the following command in the root of the repository:
```sh
> dotnet build
```
This will generate runtime artifacts for each project:
```plaintext
./DiscordChatExporter.Gui/bin/[Debug|Release]/[runtime]/*
./DiscordChatExporter.Cli/bin/[Debug|Release]/[runtime]/*
```
You can also build and run a specific project directly.
To do that, navigate to its directory and use `dotnet run`:
```sh
> cd DiscordChatExporter.Gui
> dotnet run
```
![channel list](.assets/list.png)
![rendered output](.assets/output.png)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB