mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-26 23:29:49 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b89dd4c924 | |||
| 427c6021ac | |||
| 91a8daf3a7 | |||
| 0358045c94 | |||
| 31de8a5b06 | |||
| efd430ed29 | |||
| 623c688e1b | |||
| 05f8df51e9 | |||
| e2c633b004 | |||
| b11a57a825 | |||
| f502e577c2 | |||
| 8bc9fe7c72 | |||
| 97485c280b | |||
| 5a57c4202d | |||
| acac8f7bbb | |||
| 5632d6c386 | |||
| f4d1e630f7 | |||
| 799dd1ad81 | |||
| 64a4ba296e | |||
| f0e99765f7 | |||
| 45629c9d7f | |||
| 2aec84884b | |||
| 6258394fc0 | |||
| e6472543e0 | |||
| ee163d6de7 | |||
| 5838c0546e | |||
| 07a02773be | |||
| 0a623b24d1 | |||
| da4f5ea74a | |||
| 34b5ccc75b | |||
| 7456f0fe3a | |||
| 757daa7a60 | |||
| 9590d4461a | |||
| a27fc3e72e | |||
| f10bba63e6 | |||
| b76b42549b | |||
| ae70fe9741 | |||
| af3ec8c299 | |||
| f1673cde77 | |||
| ed33d19bc0 | |||
| 80e9ad75ba | |||
| 295f4cf9a2 | |||
| 1f189e5206 | |||
| df683b3b41 | |||
| d467948d0b | |||
| f6166764e9 | |||
| 7ee2763d4b | |||
| eb99ab29ed | |||
| 5c61306231 | |||
| 2e4601d378 | |||
| cc703cc860 | |||
| 661f82c478 | |||
| b9f19f7003 | |||
| e794d7c833 | |||
| 5a26ba0b5a |
+1
-1
@@ -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).
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -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:`.
|
||||
|
||||
@@ -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 (")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.
|
||||
|
||||
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.
|
||||
|
||||
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
changelog:
|
||||
exclude:
|
||||
authors:
|
||||
- dependabot
|
||||
- dependabot[bot]
|
||||
|
||||
categories:
|
||||
- title: Enhancements
|
||||
labels:
|
||||
- enhancement
|
||||
|
||||
- title: Bugs
|
||||
labels:
|
||||
- bug
|
||||
|
||||
- title: Other
|
||||
labels:
|
||||
- "*"
|
||||
@@ -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
@@ -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,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>
|
||||
</Project>
|
||||
|
||||
+33
-30
@@ -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,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,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>();
|
||||
|
||||
+3
-3
@@ -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)
|
||||
{
|
||||
+3
-3
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user