Compare commits

..

55 Commits

Author SHA1 Message Date
tyrrrz b89dd4c924 Refactor 2026-07-25 21:18:39 +03:00
tyrrrz 427c6021ac Fix grammar 2026-07-25 20:28:29 +03:00
Kushida 91a8daf3a7 Fix reverse exports with an after boundary (#1569) 2026-07-25 20:28:06 +03:00
Cap LaBuff 0358045c94 Only display http(s) URLs as links in HTML export (#1568) 2026-07-14 12:09:42 +03:00
tyrrrz 31de8a5b06 Apply updated formatting rules 2026-07-04 18:51:27 +03:00
dependabot[bot] efd430ed29 Bump the actions group with 3 updates (#1564)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 18:49:28 +03:00
dependabot[bot] 623c688e1b Bump the nuget group with 14 updates (#1565)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 18:48:21 +03:00
tyrrrz 05f8df51e9 Refactor 2026-06-26 13:31:15 +03:00
Jacob Pfundstein e2c633b004 Include starter message in thread exports and skip placeholder (#1557)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 12:59:20 +03:00
tyrrrz b11a57a825 Fix formatting 2026-06-20 14:33:04 +03:00
Nick f502e577c2 Check for media.discordapp.net in addition to cdn.discordapp.com when stripping signature parameters from media URLs (#1554)
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2026-06-20 14:30:34 +03:00
Nick 8bc9fe7c72 Catch both old uppercase 5-char hash and newer lowercase 5-char hash when migrating to new hash (#1552)
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2026-06-20 14:16:48 +03:00
Jacob Pfundstein 97485c280b Fix output path detection to correctly identify directories and files (#1556) 2026-06-20 13:55:49 +03:00
Jacob Pfundstein 5a57c4202d Validate output path early to prevent errors when exporting multiple channels (#1555)
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2026-06-18 15:37:58 +03:00
tyrrrz acac8f7bbb Update packages 2026-06-08 15:52:10 +03:00
Oleksii Holub 5632d6c386 HTML-encode markdown content when formatting is disabled (#1545) 2026-06-04 13:09:03 +03:00
Oleksii Holub f4d1e630f7 Implement missing HTML encoding for several attribute injections (#1544) 2026-06-04 12:38:09 +03:00
Colton 799dd1ad81 Stream asset downloads to avoid OutOfMemoryException on large files (#1540)
* Stream asset downloads to avoid OutOfMemoryException on large files

ExportAssetDownloader used HttpClient.GetAsync(url, ct), which defaults to
HttpCompletionOption.ResponseContentRead and buffers the entire response body
into memory before returning. Downloading a large attachment therefore tried to
grow a single in-memory buffer to the full file size and threw
OutOfMemoryException (HttpContent.LoadIntoBufferAsync -> GrowAndWrite).

Pass HttpCompletionOption.ResponseHeadersRead so the body is streamed straight
to disk via CopyToAsync in bounded chunks, matching how DiscordClient already
issues its requests.

* Update ExportAssetDownloader.cs

---------

Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2026-06-03 15:47:43 +03:00
dependabot[bot] 64a4ba296e Bump the actions group with 4 updates (#1541)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 19:41:24 +03:00
dependabot[bot] f0e99765f7 Bump the nuget group with 13 updates (#1542)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-02 19:40:41 +03:00
tyrrrz 45629c9d7f Refactor message boxes 2026-05-26 15:00:42 +03:00
Copilot 2aec84884b Extract app shutdown fallback into App.Shutdown (#1539)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-05-25 19:10:19 +03:00
tyrrrz 6258394fc0 Remove lang version from build props 2026-05-03 16:01:32 +03:00
tyrrrz e6472543e0 Refactor fetching Avalonia TopLevel 2026-05-02 11:30:14 +03:00
dependabot[bot] ee163d6de7 Bump actions/upload-artifact from 7.0.0 to 7.0.1 in the actions group (#1528)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 16:31:01 +03:00
tyrrrz 5838c0546e Update for Avalonia 12 2026-05-01 16:30:11 +03:00
dependabot[bot] 07a02773be Bump the nuget group with 15 updates (#1529)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 16:05:31 +03:00
tyrrrz 0a623b24d1 Refactor GUI spacing 2026-04-22 17:18:28 +03:00
tyrrrz da4f5ea74a Use PowerKit's Uri.TryGetFilaName() 2026-04-20 22:22:11 +03:00
tyrrrz 34b5ccc75b Remove BOM 2026-04-19 23:13:12 +03:00
Copilot 7456f0fe3a Add PowerKit and replace custom utility extensions (#1525)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-19 23:10:45 +03:00
tyrrrz 757daa7a60 Forward slashes 2026-04-19 18:13:26 +03:00
tyrrrz 9590d4461a Standardize repo structure 2026-04-19 16:24:23 +03:00
tyrrrz a27fc3e72e Update Avalonia 2026-04-17 15:02:28 +03:00
Oleksii Holub f10bba63e6 Add warning against user account automation 2026-04-14 22:52:10 +03:00
tyrrrz b76b42549b Fix security issues 2026-04-14 13:23:38 +03:00
tyrrrz ae70fe9741 Move theme initialization to the correct place 2026-04-07 17:54:05 +03:00
tyrrrz af3ec8c299 Use CliFx v3 stable 2026-04-03 17:27:30 +03:00
tyrrrz f1673cde77 Use the Loaded event in view init 2026-04-03 11:37:44 +03:00
tyrrrz ed33d19bc0 Fix settings getting corrupted 2026-04-03 11:20:54 +03:00
tyrrrz 80e9ad75ba Clean up 2026-04-03 00:35:09 +03:00
tyrrrz 295f4cf9a2 Improve WatchProperty 2026-04-03 00:24:27 +03:00
tyrrrz 1f189e5206 Rename view model manager methods 2026-04-02 14:50:11 +03:00
tyrrrz df683b3b41 Remove unneeded usings 2026-04-02 14:36:17 +03:00
tyrrrz d467948d0b Only execute commands from code if allowed 2026-04-02 14:36:06 +03:00
tyrrrz f6166764e9 Automate view initialization 2026-04-02 14:35:17 +03:00
dependabot[bot] 7ee2763d4b Bump the nuget group with 17 updates (#1520)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 15:03:58 +03:00
dependabot[bot] eb99ab29ed Bump the actions group with 5 updates (#1519)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 15:02:33 +03:00
tyrrrz 5c61306231 Update favicon 2026-03-31 23:47:26 +03:00
tyrrrz 2e4601d378 Emit source-generated files for easier debugging 2026-03-30 21:36:11 +03:00
Oleksii Holub cc703cc860 Migrate to CliFx v3 (#1516) 2026-03-26 23:53:28 +02:00
Oleksii Holub 661f82c478 Add Nix download for GUI 2026-03-24 00:51:42 +02:00
tyrrrz b9f19f7003 Fix shebang, rename script 2026-03-23 23:34:57 +02:00
Copilot e794d7c833 Replace Publish-MacOSBundle.ps1 with a .NET 10 single-file C# script (#1515)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-03-23 23:18:46 +02:00
Oleksii Holub 5a26ba0b5a Update WinGet links in Readme 2026-03-21 23:55:12 +02:00
228 changed files with 2086 additions and 2377 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ $ docker pull tyrrrz/discordchatexporter:stable
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:
- `stable` — latest stable version release. This tag is updated with each release of a new project version. Recommended for personal use.
- `x.y.z` (e.g. `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
- `x.y.z` (e.g., `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `prime` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordchatexporter/tags?ordering=name).
+2 -2
View File
@@ -64,9 +64,9 @@ exit 0
- `tokenhere` with your [Token](Token-and-IDs.md).
- `channelhere` with a [Channel ID](Token-and-IDs.md).
- `dceFOLDERpathhere` with DCE's **directory path** (e.g. `/path/to/folder`, NOT `/path/to/folder/DiscordChatExporter.dll`).
- `dceFOLDERpathhere` with DCE's **directory path** (e.g., `/path/to/folder`, NOT `/path/to/folder/DiscordChatExporter.dll`).
- `filenamehere` with the exported channel's filename, without spaces.
- `dirhere` with the export directory (e.g. /home/user/Documents/Discord\ Exports).
- `dirhere` with the export directory (e.g., /home/user/Documents/Discord\ Exports).
- `formathere` with one of the available export formats.
> **Note**:
+2 -2
View File
@@ -68,9 +68,9 @@ exit 0
- `tokenhere` with your [Token](Token-and-IDs.md)
- `channelhere` with a [Channel ID](Token-and-IDs.md)
- `dceFOLDERpathhere` with DCE's **directory's path** (e.g. `/Users/user/Desktop/DiscordChatExporterFolder`, NOT `/Users/user/Desktop/DiscordChatExporterFolder/DiscordChatExporter.DLL`)
- `dceFOLDERpathhere` with DCE's **directory's path** (e.g., `/Users/user/Desktop/DiscordChatExporterFolder`, NOT `/Users/user/Desktop/DiscordChatExporterFolder/DiscordChatExporter.DLL`)
- `filenamehere` with the exported channel's filename, without spaces
- `dirhere` with the directory you want the files to be saved at (e.g. `/Users/user/Documents/Discord\ Exports`)
- `dirhere` with the directory you want the files to be saved at (e.g., `/Users/user/Documents/Discord\ Exports`)
- `formathere` with one of the available export formats
To quickly get file or folder paths, select the file/folder, then hit Command+I (⌘I) and copy what's after `Where:`.
+2 -2
View File
@@ -33,9 +33,9 @@ exit
- `tokenhere` with your [Token](Token-and-IDs.md)
- `channelhere` with a [Channel ID](Token-and-IDs.md)
- `exefolderhere` with the .exe **directory's path** (e.g. C:\Users\User\Desktop\DiscordChatExporter)
- `exefolderhere` with the .exe **directory's path** (e.g., C:\Users\User\Desktop\DiscordChatExporter)
- `filenamehere` with a filename without spaces
- `dirhere` with the export directory (e.g. C:\Users\User\Documents\Exports)
- `dirhere` with the export directory (e.g., C:\Users\User\Documents\Exports)
- `formathere` with one of the available export formats
Make sure not to delete the quotes (")
+4 -4
View File
@@ -77,7 +77,7 @@ format is `HtmlDark`.
#### Changing the output filename
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
You can change the filename by using `-o name.ext`. e.g., for the `HTML` format:
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
@@ -186,21 +186,21 @@ locales. The default locale is `en-US`.
#### Date ranges
**Messages sent before a date**
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
Use `--before` to export messages sent before the provided date. e.g., messages sent before September 18th, 2019:
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
```
**Messages sent after a date**
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
Use `--after` to export messages sent after the provided date. e.g., messages sent after September 17th, 2019 11:34 PM:
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
```
**Messages sent in a date range**
Use `--before` and `--after` to export messages sent during the provided date range. E.g. messages sent between
Use `--before` and `--after` to export messages sent during the provided date range. e.g., messages sent between
September 17th, 2019 11:34 PM and September 18th:
```console
+7 -7
View File
@@ -56,10 +56,11 @@ In this screen you can customize the following:
- **Export format** - HTML (Dark), HTML (Light), TXT, CSV and JSON
- **Date range (after/before)** (Optional) - If set, only messages sent in the provided date range will be exported. Only one value (either after or before) is required if you want to use this option.
> **Note**:
> Please note that the time defaults to **12:00 AM** (midnight/00:00). This means that if you choose to export between Sep 17th and Sep 18th, messages from Sep 18th won't be exported.
- **Partition limit** (Optional) - Split output into partitions, each limited to this number of messages (e.g. 100) or file size (e.g. 10mb). For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
- **Partition limit** (Optional) - Split output into partitions, each limited to this number of messages (e.g., 100) or file size (e.g., 10mb). For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
- **Message Filter** (Optional) - Special notation for filtering the messages that get included in the export. See [Message filters](Message-filters.md) for more info.
@@ -77,29 +78,28 @@ In this screen you can customize the following:
## Settings
- **Auto-update** - Perform automatic updates on every launch.
Default: Enabled
Default: Enabled
> **Note**:
> Keep this option enabled to receive the latest features and bug fixes!
- **Dark mode** - Use darker colors in the UI (User Interface).
Default: Disabled
Default: Disabled
- **Persist token** - Persist last used token between sessions.
Default: Enabled
Default: Enabled
- **Show threads** - Controls whether threads are shown in the channel list.
Default: none
Default: none
- **Locale** - Customize how dates are formatted in the exported files.
- **Date format** - Customize how dates are formatted in the exported files in the settings menu ().
- **Parallel limit** - The number of channels that will be exported at the same time.
Default: 1
Default: 1
> **Note**:
> Try to keep this number low so that your account doesn't get flagged.
- **Normalize to UTC** - Convert all dates to UTC before exporting.
+1 -1
View File
@@ -2,7 +2,7 @@ blank_issues_enabled: false
contact_links:
- name: ⚠ Feature request
url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
about: Sorry, but this project is in maintenance mode and currently doesn't accept new feature requests.
- name: 📖 Documentation
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
about: Find usage guides and frequently asked questions.
+18
View File
@@ -0,0 +1,18 @@
changelog:
exclude:
authors:
- dependabot
- dependabot[bot]
categories:
- title: Enhancements
labels:
- enhancement
- title: Bugs
labels:
- bug
- title: Other
labels:
- "*"
+5 -5
View File
@@ -23,10 +23,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Build image
run: >
@@ -37,7 +37,7 @@ jobs:
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload image
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar
@@ -55,10 +55,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to DockerHub
run: >
+10 -10
View File
@@ -26,10 +26,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
# Build the project separately to discern between build and format errors
- name: Build
@@ -62,10 +62,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
- name: Run tests
env:
@@ -81,7 +81,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -117,10 +117,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
- name: Publish app
run: >
@@ -135,7 +135,7 @@ jobs:
--self-contained
- name: Upload app binaries
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/
@@ -200,7 +200,7 @@ jobs:
steps:
- name: Download app binaries
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/
@@ -236,7 +236,7 @@ jobs:
steps:
- name: Notify Discord
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
uses: tyrrrz/action-http-request@110f1a0f0f7e91c3b2de349539249f4573c243fa # 1.1.5
with:
url: ${{ secrets.DISCORD_WEBHOOK }}
method: POST
+1 -3
View File
@@ -1,14 +1,12 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>999.9.9-dev</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
</Project>
+33 -30
View File
@@ -4,40 +4,43 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.4.0" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.6.0" />
<PackageVersion Include="AngleSharp" Version="1.5.1" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.8.0" />
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
<PackageVersion Include="Avalonia" Version="11.3.12" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.12" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.12" />
<PackageVersion Include="CliFx" Version="2.3.6" />
<PackageVersion Include="Cogwheel" Version="2.1.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
<PackageVersion Include="Deorcify" Version="1.1.0" />
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
<PackageVersion Include="Gress" Version="2.1.1" />
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
<PackageVersion Include="Markdig" Version="1.0.1" />
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.3" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="Avalonia" Version="12.0.5" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.5" />
<PackageVersion Include="CliFx" Version="3.0.0" />
<PackageVersion Include="Cogwheel" Version="2.1.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.3.0" />
<PackageVersion Include="Deorcify" Version="2.0.1" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" />
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" />
<PackageVersion Include="Gress" Version="2.2.0" />
<PackageVersion Include="JsonExtensions" Version="1.2.3" />
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageVersion
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
Version="10.0.9"
/>
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Onova" Version="2.6.13" />
<PackageVersion Include="Polly" Version="8.6.5" />
<PackageVersion Include="RazorBlade" Version="0.11.0" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="Polly" Version="8.7.0" />
<PackageVersion Include="PowerKit" Version="2.0.2" />
<PackageVersion Include="RazorBlade" Version="1.0.0" />
<PackageVersion Include="Spectre.Console" Version="0.57.1" />
<PackageVersion Include="Superpower" Version="3.1.0" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.2" />
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="YoutubeExplode" Version="6.5.7" />
<PackageVersion Include="YoutubeExplode" Version="6.6.0" />
</ItemGroup>
</Project>
@@ -14,17 +14,18 @@
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
<PackageReference Include="GitHubActionsTestLogger" />
<PackageReference Include="JsonExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="PowerKit" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
<ProjectReference Include="../DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Tests.Infra;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Reflection;
using Microsoft.Extensions.Configuration;
+1 -1
View File
@@ -1,4 +1,4 @@
# DiscordChatExporter Tests
# DiscordChatExporter Tests
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,4 +1,4 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using FluentAssertions;
using Xunit;
@@ -1,15 +1,15 @@
using System;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using JsonExtensions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -60,6 +60,52 @@ public class DateRangeSpecs
);
}
[Fact]
public async Task I_can_filter_the_reverse_export_to_only_include_messages_sent_after_the_specified_date()
{
// Arrange
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
using var file = TempFile.Create();
// Act
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
After = Snowflake.FromDate(after),
IsReverseMessageOrder = true,
}.ExecuteAsync(new FakeConsole());
// Assert
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages")
.EnumerateArray()
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
.ToArray();
timestamps.All(t => t > after).Should().BeTrue();
timestamps.Should().BeInDescendingOrder();
timestamps
.Should()
.BeEquivalentTo(
[
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
],
o =>
o.Using<DateTimeOffset>(ctx =>
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>()
);
}
[Fact]
public async Task I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date()
{
@@ -1,15 +1,15 @@
using System;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using FluentAssertions;
using JsonExtensions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
@@ -8,6 +8,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -1,11 +1,11 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -1,9 +1,9 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -22,7 +22,7 @@ public class HtmlForwardSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
}
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
@@ -8,6 +8,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -1,9 +1,9 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -22,11 +22,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Default timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -39,8 +42,11 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Short time timestamp: 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -53,8 +59,11 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Long time timestamp: 1:36:12 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -67,8 +76,11 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace(' ').Should().Contain("Short date timestamp: 2/12/2023");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -83,11 +95,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Long date timestamp: Sunday, February 12, 2023");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -102,11 +117,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -121,11 +139,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -140,11 +161,14 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.InnerHtml.ReplaceWhiteSpace(' ')
.Should()
.Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -1,4 +1,4 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
@@ -1,4 +1,4 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
@@ -1,4 +1,4 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
@@ -1,13 +1,13 @@
using System.IO;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using JsonExtensions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
@@ -1,4 +1,4 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
@@ -1,12 +1,12 @@
using System.IO;
using System.IO;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Partitioning;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -17,7 +17,7 @@ public class PartitioningSpecs
public async Task I_can_export_a_channel_with_partitioning_based_on_message_count()
{
// Arrange
using var dir = TempDir.Create();
using var dir = TempDirectory.Create();
var filePath = Path.Combine(dir.Path, "output.html");
// Act
@@ -38,7 +38,7 @@ public class PartitioningSpecs
public async Task I_can_export_a_channel_with_partitioning_based_on_file_size()
{
// Arrange
using var dir = TempDir.Create();
using var dir = TempDirectory.Create();
var filePath = Path.Combine(dir.Path, "output.html");
// Act
@@ -1,4 +1,4 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using FluentAssertions;
using Xunit;
@@ -1,7 +1,7 @@
using System.Threading.Tasks;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using FluentAssertions;
using PowerKit.Extensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -16,7 +16,7 @@ public class PlainTextForwardSpecs
// Assert
document
.ReplaceWhiteSpace()
.ReplaceWhiteSpace(' ')
.Should()
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
}
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
@@ -7,6 +7,7 @@ using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using PowerKit;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -17,7 +18,7 @@ public class SelfContainedSpecs
public async Task I_can_export_a_channel_and_download_all_referenced_assets()
{
// Arrange
using var dir = TempDir.Create();
using var dir = TempDirectory.Create();
var filePath = Path.Combine(dir.Path, "output.html");
// Act
@@ -1,19 +0,0 @@
using System.Text;
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
internal static class StringExtensions
{
extension(string str)
{
public string ReplaceWhiteSpace(string replacement = " ")
{
var buffer = new StringBuilder(str.Length);
foreach (var ch in str)
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
return buffer.ToString();
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
using AngleSharp.Html.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Html.Parser;
namespace DiscordChatExporter.Cli.Tests.Utils;
@@ -1,36 +0,0 @@
using System;
using System.IO;
using System.Reflection;
namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempDir(string path) : IDisposable
{
public string Path { get; } = path;
public void Dispose()
{
try
{
Directory.Delete(Path, true);
}
catch (DirectoryNotFoundException) { }
}
}
internal partial class TempDir
{
public static TempDir Create()
{
var dirPath = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(),
"Temp",
Guid.NewGuid().ToString()
);
Directory.CreateDirectory(dirPath);
return new TempDir(dirPath);
}
}
@@ -1,37 +0,0 @@
using System;
using System.IO;
using System.Reflection;
namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempFile(string path) : IDisposable
{
public string Path { get; } = path;
public void Dispose()
{
try
{
File.Delete(Path);
}
catch (FileNotFoundException) { }
}
}
internal partial class TempFile
{
public static TempFile Create()
{
var dirPath = System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(),
"Temp"
);
Directory.CreateDirectory(dirPath);
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
return new TempFile(filePath);
}
}
@@ -1,4 +1,4 @@
{
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"methodDisplayOptions": "all",
"methodDisplay": "method"
@@ -1,8 +1,8 @@
using System;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
@@ -17,23 +17,22 @@ public abstract class DiscordCommandBase : ICommand
EnvironmentVariable = "DISCORD_TOKEN",
Description = "Authentication token."
)]
public required string Token { get; init; }
public required string Token { get; set; }
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
[CommandOption(
"bot",
'b',
EnvironmentVariable = "DISCORD_TOKEN_BOT",
Description = "This option doesn't do anything. Kept for backwards compatibility."
)]
public bool IsBotToken { get; init; } = false;
public bool IsBotToken { get; set; } = false;
[CommandOption(
"respect-rate-limits",
Description = "Whether to respect advisory rate limits. "
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
+ "If disabled, only hard rate limits (i.e., 429 responses) will be respected."
)]
public bool ShouldRespectRateLimits { get; init; } = true;
public bool ShouldRespectRateLimits { get; set; } = true;
[field: AllowNull, MaybeNull]
protected DiscordClient Discord =>
@@ -1,12 +1,12 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
@@ -37,75 +37,75 @@ public abstract class ExportCommandBase : DiscordCommandBase
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => field = Path.GetFullPath(value);
set => field = Path.GetFullPath(value);
} = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
[CommandOption(
"after",
Description = "Only include messages sent after this date or message ID."
)]
public Snowflake? After { get; init; }
public Snowflake? After { get; set; }
[CommandOption(
"before",
Description = "Only include messages sent before this date or message ID."
)]
public Snowflake? Before { get; init; }
public Snowflake? Before { get; set; }
[CommandOption(
"partition",
'p',
Description = "Split the output into partitions, each limited to the specified "
+ "number of messages (e.g. '100') or file size (e.g. '10mb')."
+ "number of messages (e.g., '100') or file size (e.g., '10mb')."
)]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
public PartitionLimit PartitionLimit { get; set; } = PartitionLimit.Null;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
Converter = typeof(ThreadInclusionModeInputConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
[CommandOption(
"filter",
Description = "Only include messages that satisfy this filter. "
+ "See the documentation for more info."
)]
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
public MessageFilter MessageFilter { get; set; } = MessageFilter.Null;
[CommandOption(
"parallel",
Description = "Limits how many channels can be exported in parallel."
)]
public int ParallelLimit { get; init; } = 1;
public int ParallelLimit { get; set; } = 1;
[CommandOption(
"reverse",
Description = "Export messages in reverse chronological order (newest first)."
)]
public bool IsReverseMessageOrder { get; init; }
public bool IsReverseMessageOrder { get; set; }
[CommandOption(
"markdown",
Description = "Process markdown, mentions, and other special tokens."
)]
public bool ShouldFormatMarkdown { get; init; } = true;
public bool ShouldFormatMarkdown { get; set; } = true;
[CommandOption(
"media",
Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
)]
public bool ShouldDownloadAssets { get; init; }
public bool ShouldDownloadAssets { get; set; }
[CommandOption(
"reuse-media",
Description = "Reuse previously downloaded assets to avoid redundant requests."
)]
public bool ShouldReuseAssets { get; init; } = false;
public bool ShouldReuseAssets { get; set; } = false;
[CommandOption(
"media-dir",
@@ -117,34 +117,33 @@ public abstract class ExportCommandBase : DiscordCommandBase
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => field = value is not null ? Path.GetFullPath(value) : null;
set => field = value is not null ? Path.GetFullPath(value) : null;
}
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
[CommandOption(
"dateformat",
Description = "This option doesn't do anything. Kept for backwards compatibility."
)]
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
public string DateFormat { get; set; } = "MM/dd/yyyy h:mm tt";
[CommandOption(
"locale",
Description = "Locale to use when formatting dates and numbers. "
+ "If not specified, the default system locale will be used."
)]
public string? Locale { get; init; }
public string? Locale { get; set; }
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
public bool IsUtcNormalizationEnabled { get; init; } = false;
public bool IsUtcNormalizationEnabled { get; set; } = false;
[CommandOption(
"fuck-russia",
EnvironmentVariable = "FUCK_RUSSIA",
Description = "Don't print the Support Ukraine message to the console.",
// Use a converter to accept '1' as 'true' to reuse the existing environment variable
Converter = typeof(TruthyBooleanBindingConverter)
Converter = typeof(TruthyBooleanInputConverter)
)]
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
public bool IsUkraineSupportMessageDisabled { get; set; } = false;
[field: AllowNull, MaybeNull]
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
@@ -166,6 +165,37 @@ public abstract class ExportCommandBase : DiscordCommandBase
throw new CommandException("Option --media-dir cannot be used without --media.");
}
// Make sure the user does not try to export multiple channels into one file.
// Output path must either be a directory or contain template tokens for this to work.
// Validate this up-front, before fetching threads, because thread fetching can take a
// long time and it's frustrating to fail only after it completes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1549
var mayExportMultipleChannels =
// Multiple channels were provided explicitly
channels.Count > 1
// Thread inclusion can add more channels to the export
|| ThreadInclusionMode != ThreadInclusionMode.None;
var isValidOutputPath =
// Anything is valid when exporting a single channel
!mayExportMultipleChannels
// When using template tokens, assume the user knows what they're doing
|| OutputPath.Contains('%')
// Otherwise, require an existing directory or an unambiguous directory path
|| Directory.Exists(OutputPath)
|| Path.EndsInDirectorySeparator(OutputPath);
if (!isValidOutputPath)
{
throw new CommandException(
"Attempted to export multiple channels, but the output path is neither a directory nor a template. "
+ "If the provided output path is meant to be treated as a directory, make sure it ends with a slash. "
+ $"Provided output path: '{OutputPath}'."
);
}
var unwrappedChannels = new List<Channel>(channels);
// Unwrap threads
@@ -206,28 +236,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
}
// Make sure the user does not try to export multiple channels into one file.
// Output path must either be a directory or contain template tokens for this to work.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath =
// Anything is valid when exporting a single channel
unwrappedChannels.Count <= 1
// When using template tokens, assume the user knows what they're doing
|| OutputPath.Contains('%')
// Otherwise, require an existing directory or an unambiguous directory path
|| Directory.Exists(OutputPath)
|| Path.EndsInDirectorySeparator(OutputPath);
if (!isValidOutputPath)
{
throw new CommandException(
"Attempted to export multiple channels, but the output path is neither a directory nor a template. "
+ "If the provided output path is meant to be treated as a directory, make sure it ends with a slash. "
+ $"Provided output path: '{OutputPath}'."
);
}
// Export
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
@@ -1,10 +1,10 @@
using System;
using CliFx.Extensibility;
using System;
using CliFx.Activation;
using DiscordChatExporter.Cli.Commands.Shared;
namespace DiscordChatExporter.Cli.Commands.Converters;
internal class ThreadInclusionModeBindingConverter : BindingConverter<ThreadInclusionMode>
internal class ThreadInclusionModeInputConverter : ScalarInputConverter<ThreadInclusionMode>
{
public override ThreadInclusionMode Convert(string? rawValue)
{
@@ -1,9 +1,9 @@
using System.Globalization;
using CliFx.Extensibility;
using System.Globalization;
using CliFx.Activation;
namespace DiscordChatExporter.Cli.Commands.Converters;
internal class TruthyBooleanBindingConverter : BindingConverter<bool>
internal class TruthyBooleanInputConverter : ScalarInputConverter<bool>
{
public override bool Convert(string? rawValue)
{
@@ -2,7 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
@@ -14,23 +14,23 @@ using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportall", Description = "Exports all accessible channels.")]
public class ExportAllCommand : ExportCommandBase
public partial class ExportAllCommand : ExportCommandBase
{
[CommandOption("include-dm", Description = "Include direct message channels.")]
public bool IncludeDirectChannels { get; init; } = true;
public bool IncludeDirectChannels { get; set; } = true;
[CommandOption("include-guilds", Description = "Include server channels.")]
public bool IncludeGuildChannels { get; init; } = true;
public bool IncludeGuildChannels { get; set; } = true;
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
public bool IncludeVoiceChannels { get; set; } = true;
[CommandOption(
"data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. "
+ "If provided, only channels referenced in the dump will be exported."
)]
public string? DataPackageFilePath { get; init; }
public string? DataPackageFilePath { get; set; }
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -1,16 +1,16 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("export", Description = "Exports one or multiple channels.")]
public class ExportChannelsCommand : ExportCommandBase
public partial class ExportChannelsCommand : ExportCommandBase
{
// TODO: change this to plural (breaking change)
[CommandOption(
@@ -19,7 +19,7 @@ public class ExportChannelsCommand : ExportCommandBase
Description = "Channel ID(s). "
+ "If provided with category ID(s), all channels inside those categories will be exported."
)]
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
public required IReadOnlyList<Snowflake> ChannelIds { get; set; }
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -1,14 +1,14 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using System.Threading.Tasks;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportdm", Description = "Exports all direct message channels.")]
public class ExportDirectMessagesCommand : ExportCommandBase
public partial class ExportDirectMessagesCommand : ExportCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
@@ -11,13 +11,13 @@ using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Exports all channels within the specified server.")]
public class ExportGuildCommand : ExportCommandBase
public partial class ExportGuildCommand : ExportCommandBase
{
[CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; }
public required Snowflake GuildId { get; set; }
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
public bool IncludeVoiceChannels { get; set; } = true;
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -1,31 +1,31 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("channels", Description = "Get the list of channels in a server.")]
public class GetChannelsCommand : DiscordCommandBase
public partial class GetChannelsCommand : DiscordCommandBase
{
[CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; }
public required Snowflake GuildId { get; set; }
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
public bool IncludeVoiceChannels { get; set; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
Converter = typeof(ThreadInclusionModeInputConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -1,16 +1,16 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("dm", Description = "Gets the list of all direct message channels.")]
public class GetDirectChannelsCommand : DiscordCommandBase
public partial class GetDirectChannelsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -1,16 +1,16 @@
using System;
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("guilds", Description = "Gets the list of accessible servers.")]
public class GetGuildsCommand : DiscordCommandBase
public partial class GetGuildsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -1,13 +1,13 @@
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands;
[Command("guide", Description = "Explains how to obtain the token, server or channel ID.")]
public class GuideCommand : ICommand
public partial class GuideCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Cli.Commands.Shared;
namespace DiscordChatExporter.Cli.Commands.Shared;
public enum ThreadInclusionMode
{
@@ -1,26 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
<ApplicationIcon>../favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup>
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
<PropertyGroup>
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
<EnableAotAnalyzer>false</EnableAotAnalyzer>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" />
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="Deorcify" PrivateAssets="all" />
<PackageReference Include="Deorcify" />
<PackageReference Include="Gress" />
<PackageReference Include="PowerKit" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
<ProjectReference Include="../DiscordChatExporter.Core/DiscordChatExporter.Core.csproj" />
</ItemGroup>
</Project>
+2 -30
View File
@@ -1,41 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
namespace DiscordChatExporter.Cli;
public static class Program
{
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
[DynamicDependency(
DynamicallyAccessedMemberTypes.All,
typeof(ThreadInclusionModeBindingConverter)
)]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
public static async Task<int> Main(string[] args) =>
await new CliApplicationBuilder()
.AddCommand<ExportAllCommand>()
.AddCommand<ExportChannelsCommand>()
.AddCommand<ExportDirectMessagesCommand>()
.AddCommand<ExportGuildCommand>()
.AddCommand<GetChannelsCommand>()
.AddCommand<GetDirectChannelsCommand>()
.AddCommand<GetGuildsCommand>()
.AddCommand<GuideCommand>()
await new CommandLineApplicationBuilder()
.AddCommandsFromThisAssembly()
.Build()
.RunAsync(args);
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using Spectre.Console;
@@ -1,6 +1,6 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using System.Text.Json;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,4 +1,4 @@
using System;
using System;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,9 +1,9 @@
using System;
using System;
using System.IO;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
public enum ChannelKind
@@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Discord.Data.Common;
namespace DiscordChatExporter.Core.Discord.Data.Common;
public interface IHasId
{
@@ -1,4 +1,4 @@
using System;
using System;
using System.Globalization;
using System.Linq;
@@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
@@ -47,9 +47,7 @@ public partial record Embed
var kind =
json.GetPropertyOrNull("type")
?.GetStringOrNull()
?.Pipe(s =>
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
)
.Pipe(s => Enum.ParseOrNull<EmbedKind>(s, true))
?? EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
@@ -58,7 +56,7 @@ public partial record Embed
var color = json.GetPropertyOrNull("color")
?.GetInt32OrNull()
?.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha();
.WithFullAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
@@ -74,7 +72,7 @@ public partial record Embed
// Under the Discord API model, embeds can only have at most one image.
// Because of that, embeds that are rendered with multiple images on the client
// (e.g. tweet embeds), are exposed from the API as multiple separate embeds.
// (e.g., tweet embeds), are exposed from the API as multiple separate embeds.
// Our embed model is consistent with the user-facing side of Discord, so images
// are stored as an array. The API will only ever return one image, but we deal
// with this by merging related embeds at the end of the message parsing process.
@@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
@@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record YouTubeVideoEmbedProjection(string VideoId)
{
@@ -1,8 +1,8 @@
using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -10,14 +10,14 @@ namespace DiscordChatExporter.Core.Discord.Data;
public partial record Emoji(
// Only present on custom emoji
Snowflake? Id,
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
// Name of a custom emoji (e.g., LUL) or actual representation of a standard emoji (e.g., 🙂)
string Name,
bool IsAnimated
)
{
public bool IsCustomEmoji { get; } = Id is not null;
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
// Name of a custom emoji (e.g., LUL) or name of a standard emoji (e.g., slight_smile)
public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public string ImageUrl { get; } =
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,6 +1,6 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using System.Text.Json;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,9 +1,9 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,11 +1,11 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,4 +1,4 @@
using System;
using System;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-object-message-types
public enum MessageKind
@@ -13,4 +13,5 @@ public enum MessageKind
GuildMemberJoin = 7,
ThreadCreated = 18,
Reply = 19,
ThreadStarterMessage = 21,
}
@@ -1,6 +1,6 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,5 +1,5 @@
using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions;
using System.Text.Json;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,8 +1,8 @@
using System.Drawing;
using System.Drawing;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -18,7 +18,7 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
var color = json.GetPropertyOrNull("color")
?.GetInt32OrNull()
?.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha()
.WithFullAlpha()
.NullIf(c => c.ToRgb() <= 0);
return new Role(id, name, position, color);
@@ -1,8 +1,8 @@
using System;
using System;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Discord.Data;
public enum StickerFormat
{
@@ -1,7 +1,7 @@
using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data;
+161 -105
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -11,10 +11,10 @@ using System.Threading.Tasks;
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;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord;
@@ -30,9 +30,8 @@ public class DiscordClient(
string url,
TokenKind tokenKind,
CancellationToken cancellationToken = default
)
{
return await Http.ResponseResiliencePipeline.ExecuteAsync(
) =>
await Http.ResponseResiliencePipeline.ExecuteAsync(
async innerCancellationToken =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
@@ -59,12 +58,12 @@ public class DiscordClient(
{
var remainingRequestCount = response
.Headers.TryGetValue("X-RateLimit-Remaining")
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
?.Pipe(s => int.ParseOrNull(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response
.Headers.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds);
?.Pipe(s => double.ParseOrNull(s, CultureInfo.InvariantCulture))
?.Pipe(TimeSpan.FromSeconds);
// If this was the last request available before hitting the rate limit,
// wait out the reset time so that future requests can succeed.
@@ -91,7 +90,6 @@ public class DiscordClient(
},
cancellationToken
);
}
private async ValueTask<TokenKind> ResolveTokenKindAsync(
CancellationToken cancellationToken = default
@@ -161,7 +159,7 @@ public class DiscordClient(
$"""
Request to '{url}' failed: {response
.StatusCode.ToString()
.ToSpaceSeparatedWords()
.SeparateWords(' ')
.ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(
cancellationToken
@@ -364,6 +362,7 @@ public class DiscordClient(
$"guilds/{guildId}/members/{memberId}",
cancellationToken
);
return response?.Pipe(j => Member.Parse(j, guildId));
}
@@ -412,14 +411,12 @@ public class DiscordClient(
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
Channel? parent = null;
if (parentId is not null)
{
// It's possible for the parent channel to be inaccessible, despite the
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
}
// It's possible for the parent channel to be inaccessible, despite the
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
var parent = parentId is not null
? await TryGetChannelAsync(parentId.Value, cancellationToken)
: null;
return Channel.Parse(response.Value, parent);
}
@@ -595,6 +592,33 @@ public class DiscordClient(
}
}
public async ValueTask<Message?> TryGetMessageAsync(
Snowflake channelId,
Snowflake messageId,
CancellationToken cancellationToken = default
)
{
// Use the regular message listing endpoint with the 'around' parameter instead of the
// dedicated single-message endpoint, because the latter is not accessible to user tokens.
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("around", messageId.ToString())
.SetQueryParameter("limit", "1")
.Build();
// Can be null on channels that the user cannot access
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
return null;
// The endpoint returns messages around the requested ID, so make sure to only return
// the message that exactly matches it (it may be absent if it has been deleted).
return response
.Value.EnumerateArray()
.Select(Message.Parse)
.FirstOrDefault(m => m.Id == messageId);
}
private async ValueTask<Message?> TryGetFirstMessageAsync(
Snowflake channelId,
Snowflake? after = null,
@@ -607,8 +631,12 @@ public class DiscordClient(
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
// Can be null on channels that the user cannot access
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
return null;
var message = response.Value.EnumerateArray().Select(Message.Parse).FirstOrDefault();
return message;
}
@@ -625,36 +653,64 @@ public class DiscordClient(
.SetQueryParameter("before", before?.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
// Can be null on channels that the user cannot access
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
return null;
return response.Value.EnumerateArray().Select(Message.Parse).LastOrDefault();
}
public async IAsyncEnumerable<Message> GetMessagesAsync(
private async IAsyncEnumerable<Message> GetMessagesAsync(
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
Snowflake? after,
Snowflake? before,
IProgress<Percentage>? progress,
bool isReverse,
[EnumeratorCancellation] CancellationToken cancellationToken
)
{
// Get the last message in the specified range, so we can later calculate the
// progress based on the difference between message timestamps.
// This also snapshots the boundaries, which means that messages posted after
// the export started will not appear in the output.
var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken);
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
// To keep the understanding of message history independent of the fetching direction,
// we'll refer to the two ends of the range as Alpha and Omega.
// Depending on the direction, these are either 'before' and 'after', or 'after' and 'before'.
//
// Chronological order:
// <after> Alpha [----->----->----] Omega <before>
// Reverse chronological order:
// <after> Omega [-----<-----<----] Alpha <before>
// Because Discord API doesn't allow us to provide both 'after' and 'before' parameters
// at the same time, we have to establish at least one end of the boundary manually.
// To do that, we'll fetch the Omega message, which will be the terminal message in the range:
// last message in chronological order, or first message in reverse chronological order.
// This snapshotting also has the side benefit of allowing us to calculate progress by comparing
// the timestamps of the Alpha message, Omega message, and the message being currently processed.
var omegaMessage = !isReverse
? await TryGetLastMessageAsync(channelId, before, cancellationToken)
: await TryGetFirstMessageAsync(channelId, after, cancellationToken);
// If the Omega doesn't exist or falls outside of the range, then there are simply no messages
// satisfying the specified range.
if (
omegaMessage is null
|| (!isReverse && omegaMessage.Timestamp < after?.ToDate())
|| (isReverse && omegaMessage.Timestamp > before?.ToDate())
)
{
yield break;
}
// Keep track of the first message in range in order to calculate the progress
var firstMessage = default(Message);
// Persist the Alpha message as soon as we fetch the initial batch of messages.
// This is only used for calculating progress.
var alphaMessage = default(Message);
var currentAfter = after ?? Snowflake.Zero;
var currentBoundary = !isReverse ? after ?? Snowflake.Zero : before;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString())
.SetQueryParameter(!isReverse ? "after" : "before", currentBoundary?.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
@@ -662,8 +718,8 @@ public class DiscordClient(
var messages = response
.EnumerateArray()
.Select(Message.Parse)
// Messages are returned from newest to oldest, so we need to reverse them
.Reverse()
// Messages in batches are always returned from newest to oldest, so reverse if needed
.Pipe(messages => isReverse ? messages : messages.Reverse())
.ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
@@ -678,32 +734,78 @@ public class DiscordClient(
foreach (var message in messages)
{
firstMessage ??= message;
// Ensure that the messages are in range
if (message.Timestamp > lastMessage.Timestamp)
// Ensure that we're still in range by checking against the Omega
if (!isReverse ? message.Id > omegaMessage.Id : message.Id < omegaMessage.Id)
{
yield break;
}
alphaMessage ??= message;
// Report progress based on timestamps
if (progress is not null)
{
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
var fetchedDuration = isReverse
? alphaMessage.Timestamp - message.Timestamp
: message.Timestamp - alphaMessage.Timestamp;
var totalDuration = isReverse
? alphaMessage.Timestamp - omegaMessage.Timestamp
: omegaMessage.Timestamp - alphaMessage.Timestamp;
progress.Report(
Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp
// (which happens when there's only one message in the channel)
totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration
? fetchedDuration / totalDuration
: 1
)
);
}
yield return message;
currentAfter = message.Id;
// Some messages, for example thread starter messages, are returned by the API as content-less references.
// Try to resolve them to the actual message so that they appear as they do in the Discord client.
var actualMessage =
message.Kind == MessageKind.ThreadStarterMessage
&& message.Reference?.ChannelId is { } referencedChannelId
&& message.Reference?.MessageId is { } referencedMessageId
? await TryGetMessageAsync(
referencedChannelId,
referencedMessageId,
cancellationToken
)
: null;
yield return actualMessage ?? message;
}
// The new boundary is always determined by the last message in the batch,
// because we order the messages based on fetching direction.
currentBoundary = messages.Last().Id;
}
}
public async IAsyncEnumerable<Message> GetMessagesAsync(
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
await foreach (
var message in GetMessagesAsync(
channelId,
after,
before,
progress,
false,
cancellationToken
)
)
{
yield return message;
}
}
@@ -715,64 +817,18 @@ public class DiscordClient(
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
// Get the first message in the specified range, so we can later calculate the
// progress based on the difference between message timestamps.
// Snapshotting is not necessary here because new messages can't appear in the past.
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
yield break;
// Keep track of the last message in range in order to calculate the progress
var lastMessage = default(Message);
var currentBefore = before;
while (true)
await foreach (
var message in GetMessagesAsync(
channelId,
after,
before,
progress,
true,
cancellationToken
)
)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100")
.SetQueryParameter("before", currentBefore?.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
yield break;
// If all messages are empty, make sure that it's not because the bot account doesn't
// have the MESSAGE_CONTENT intent enabled.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
if (messages.All(m => m.IsEmpty))
await EnsureMessageContentIntentAsync(cancellationToken);
foreach (var message in messages)
{
lastMessage ??= message;
// Report progress based on timestamps
if (progress is not null)
{
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
progress.Report(
Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp
// (which happens when there's only one message in the channel)
totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration
: 1
)
);
}
yield return message;
}
currentBefore = messages.Last().Id;
yield return message;
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO.Compression;
using System.Linq;
@@ -1,3 +1,3 @@
namespace DiscordChatExporter.Core.Discord.Dump;
namespace DiscordChatExporter.Core.Discord.Dump;
public record DataDumpChannel(Snowflake Id, string Name);
@@ -1,4 +1,4 @@
using System;
using System;
namespace DiscordChatExporter.Core.Discord;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
@@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Core.Discord;
public enum TokenKind
{
@@ -6,6 +6,7 @@
<PackageReference Include="Gress" />
<PackageReference Include="JsonExtensions" />
<PackageReference Include="Polly" />
<PackageReference Include="PowerKit" />
<PackageReference Include="RazorBlade" />
<PackageReference Include="Superpower" />
<PackageReference Include="WebMarkupMin.Core" />
@@ -1,4 +1,4 @@
using System;
using System;
namespace DiscordChatExporter.Core.Exceptions;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord;
@@ -1,11 +1,11 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;
@@ -1,15 +1,15 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using AsyncKeyedLock;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Exporting;
@@ -38,23 +38,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
return _previousPathsByUrl[url] = filePath;
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
// to the new naming scheme to preserve backwards compatibility with existing exports
// to the new naming scheme to preserve backwards compatibility with existing exports.
// This will catch both the 5-char lowercase hash and the 5-char uppercase hash variants.
if (reuse)
{
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
if (File.Exists(legacyFilePath))
var legacyFileNames = GetLegacyFileNamesFromUrl(url);
foreach (var legacyFileName in legacyFileNames)
{
// Overwrite in case the destination file was created concurrently between our
// earlier existence check and this move operation
try
var legacyFilePath = Path.Combine(workingDirPath, legacyFileName);
if (File.Exists(legacyFilePath))
{
File.Move(legacyFilePath, filePath, overwrite: true);
return _previousPathsByUrl[url] = filePath;
}
catch (IOException)
{
// The legacy file was moved or deleted concurrently or something else happened.
// Upgrading old files is not crucial, so we can just move on.
// Overwrite in case the destination file was created concurrently between our
// earlier existence check and this move operation
try
{
File.Move(legacyFilePath, filePath, true);
return _previousPathsByUrl[url] = filePath;
}
catch (IOException)
{
// The legacy file was moved or deleted concurrently or something else happened.
// Upgrading old files is not crucial, so we can just move on.
}
}
}
}
@@ -65,7 +70,14 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
async innerCancellationToken =>
{
// Download the file
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
using var response = await Http.Client.GetAsync(
url,
HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
response.EnsureSuccessStatusCode();
await using var output = File.Create(filePath);
await response.Content.CopyToAsync(output, innerCancellationToken);
},
@@ -80,10 +92,16 @@ internal partial class ExportAssetDownloader
{
private static string NormalizeUrl(string url)
{
// Remove signature parameters from Discord CDN URLs to normalize them
// Remove signature parameters from Discord CDN/media URLs to normalize them
var uri = new Uri(url);
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
if (
!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(uri.Host, "media.discordapp.net", StringComparison.OrdinalIgnoreCase)
)
{
return url;
}
var query = HttpUtility.ParseQueryString(uri.Query);
query.Remove("ex");
@@ -96,7 +114,7 @@ internal partial class ExportAssetDownloader
private static string GetFileNameFromUrl(string url, string urlHash)
{
// Try to extract the file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
var fileName = new Uri(url, UriKind.RelativeOrAbsolute).TryGetFileName();
// If it's not there, just use the URL hash as the file name
if (string.IsNullOrWhiteSpace(fileName))
@@ -130,13 +148,16 @@ internal partial class ExportAssetDownloader
);
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
private static string GetLegacyFileNameFromUrl(string url) =>
GetFileNameFromUrl(
url,
SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.Pipe(Convert.ToHexStringLower)
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
.Truncate(5)
);
private static IReadOnlyList<string> GetLegacyFileNamesFromUrl(string url)
{
var hashData = SHA256.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)));
return
[
// Lowercase variant (introduced in 2.46.1)
GetFileNameFromUrl(url, Convert.ToHexStringLower(hashData).Truncate(5)),
// Uppercase variant (original)
GetFileNameFromUrl(url, Convert.ToHexString(hashData).Truncate(5)),
];
}
}

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