Compare commits

...

134 Commits

Author SHA1 Message Date
Copilot 72f9e981de Replace YouTube iframe embeds with clickable thumbnails (#1474)
* Initial plan

* Replace YouTube iframe with clickable thumbnail

- Changed YouTube embed URL from embed format to watch format
- Added ThumbnailUrl property to YouTubeVideoEmbedProjection using YouTube's standard thumbnail URL
- Updated MessageGroupTemplate to render thumbnail image with link instead of iframe
- Updated CSS to style thumbnail appropriately
- Updated test to check for anchor link and thumbnail image instead of iframe

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Address code review feedback

- Use hqdefault.jpg instead of maxresdefault.jpg (guaranteed to exist for all videos)
- Extract thumbnail URL logic to GetThumbnailUrl method for better testability

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Simplify YouTube embed test to focus on data not structure

- Remove checks for HTML structure (class names, nested elements)
- Focus on verifying YouTube URL with correct video ID exists
- Follow same pattern as other embed tests (Spotify, Twitch)

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Fix data-canonical-url and improve test specificity

- Use canonical (non-proxy) URL for data-canonical-url attribute
- Extract thumbnailUrl to local variable to avoid duplicate calls
- Update test to check for img with video ID in src, avoiding false positives
- Test now verifies the actual thumbnail data rather than any link

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Inline thumbnail URL logic directly into attributes

- Remove GetThumbnailUrl method from YouTubeVideoEmbedProjection
- Remove local variables from template
- Put coalescing logic directly in src and data-canonical-url attributes

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-12 19:04:17 +02:00
Copilot 81e286751f Add file:// protocol to absolute asset paths in HTML exports (#1475)
* Initial plan

* Add file:// protocol prefix to absolute paths in HTML exports

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Improve absolute path detection for cross-platform compatibility

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Remove unused System.IO import

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Use Path.IsPathFullyQualified for absolute path detection (fixes #1473)

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-12 13:19:12 +02:00
dependabot[bot] 225230a20f Bump the actions group with 2 updates (#1467)
Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-dotnet](https://github.com/actions/setup-dotnet).


Updates `actions/checkout` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

Updates `actions/setup-dotnet` from 5.0.1 to 5.1.0
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/2016bd2012dba4e32de620c46fe006a3ac9f0602...baa11fbfe1d6520db94683bd5c7a3818018e4309)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 17:28:59 +02:00
dependabot[bot] dea412ff25 Bump the nuget group with 9 updates (#1468)
Bumps AsyncKeyedLock from 7.1.8 to 8.0.1
Bumps Avalonia from 11.3.10 to 11.3.11
Bumps Avalonia.Desktop from 11.3.10 to 11.3.11
Bumps Avalonia.Diagnostics from 11.3.10 to 11.3.11
Bumps DialogHost.Avalonia from 0.10.3 to 0.10.4
Bumps Microsoft.Extensions.Configuration from 10.0.1 to 10.0.2
Bumps Microsoft.Extensions.Configuration.EnvironmentVariables from 10.0.1 to 10.0.2
Bumps Microsoft.Extensions.Configuration.UserSecrets from 10.0.1 to 10.0.2
Bumps Microsoft.Extensions.DependencyInjection from 10.0.1 to 10.0.2

---
updated-dependencies:
- dependency-name: AsyncKeyedLock
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-version: 11.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Desktop
  dependency-version: 11.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Diagnostics
  dependency-version: 11.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: DialogHost.Avalonia
  dependency-version: 0.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.EnvironmentVariables
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.UserSecrets
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.DependencyInjection
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 17:28:47 +02:00
Tyrrrz 95953f8cf0 Refactor 2026-01-01 23:56:12 +02:00
Tyrrrz 6191855e33 Fix invalid issue link in comment 2026-01-01 23:53:57 +02:00
Copilot 33a975b907 Fix case-insensitive lookup for data package index file (#1460)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-01 21:19:59 +02:00
Tyrrrz 3d353bc1e6 Format 2026-01-01 20:40:37 +02:00
dependabot[bot] f555ab3f00 Bump the actions group with 5 updates (#1457)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 20:37:11 +02:00
dependabot[bot] a14a17db83 Bump the nuget group with 11 updates (#1458)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 20:36:41 +02:00
Tyrrrz 1f63d4fa26 Line breaks 2025-12-31 18:57:08 +02:00
Copilot d3d078735a Preserve original URLs for proxied embed assets (#1456)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-12-31 18:54:04 +02:00
Tyrrrz 58dd40bf3e Refactor last commit 2025-12-29 20:11:05 +02:00
Copilot 7c79e0639b Fix filename sanitization for NTFS filesystems on non-Windows systems (#1455)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-12-29 19:52:59 +02:00
Tyrrrz a74ba4da0c Update license year 2025-12-06 19:20:21 +02:00
Tyrrrz 8bbb8fa5ee Clean up 2025-12-03 23:37:23 +02:00
CarcaBot a91337ccac Fixed token retrieval through console (#1402) 2025-12-02 16:43:00 +02:00
Tyrrrz 303f5667bf Remove unused package 2025-12-01 20:51:03 +02:00
dependabot[bot] 934cb526e9 Bump the nuget group with 17 updates (#1445)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 20:47:52 +02:00
dependabot[bot] c33b742dce Bump the actions group with 2 updates (#1446)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 20:47:02 +02:00
Tyrrrz f0a74fbc6e Simplify 2025-11-30 19:24:37 +02:00
Tyrrrz 768e124370 Remove unnecessary extension 2025-11-30 19:23:01 +02:00
Tyrrrz 8dc7eb2937 Use markdown alerts 2025-11-24 17:58:06 +02:00
Tyrrrz 8fff0f4445 Prettify project files 2025-11-18 19:07:17 +02:00
Tyrrrz 0fc8124aa0 Cleanup the trim/aot analyzer hack 2025-11-18 00:55:23 +02:00
Tyrrrz 64ed6acf34 Don't use type aliases 2025-11-18 00:36:47 +02:00
Tyrrrz e958600073 Disable trim warnings in CLI as a workaround for C# 14 extension block issue 2025-11-16 23:28:18 +02:00
Tyrrrz 714d2e6806 Update sln 2025-11-16 20:55:00 +02:00
Tyrrrz fbbac2afaa Make use of C# 14 features 2025-11-16 20:29:39 +02:00
Tyrrrz 380dd6d511 Update to .NET 10 2025-11-11 19:46:22 +02:00
dependabot[bot] 607ec7442f Bump the actions group with 2 updates (#1440)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 13:05:14 +02:00
dependabot[bot] e6f675bcaa Bump the nuget group with 14 updates (#1441)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 13:05:05 +02:00
Tyrrrz 42ebb9928e Fix CD workflow 2025-10-04 04:46:01 +03:00
Tyrrrz 7e64aa5a8e Add default to PublishMacOSBundle to avoid error when publishing without extra args 2025-10-04 04:25:40 +03:00
dependabot[bot] a383722469 Bump the nuget group with 13 updates (#1431)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 16:08:07 +03:00
dependabot[bot] 8f3fe7e501 Bump the actions group with 2 updates (#1430)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 16:07:47 +03:00
Tyrrrz 27a434a663 Format 2025-09-02 19:55:09 +03:00
dependabot[bot] a966bee140 Bump the nuget group with 12 updates (#1429)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 19:50:14 +03:00
dependabot[bot] fb17e86146 Bump the actions group with 3 updates (#1428)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 19:49:59 +03:00
dependabot[bot] 8fcf5e5377 Bump the nuget group with 7 updates (#1417)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 16:59:39 +03:00
Tyrrrz 845e2a52fe Fix guilds -> servers 2025-07-09 01:10:56 +03:00
Tyrrrz 187befcf11 Remove the now unnecessary extension method 2025-07-01 19:02:48 +03:00
dependabot[bot] 9e685a0375 Bump the nuget group with 15 updates (#1408)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 19:00:52 +03:00
dependabot[bot] 0d98e1084d Bump docker/setup-buildx-action in the actions group (#1409)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 18:59:12 +03:00
Tyrrrz f149fad406 Potato potato 2025-06-26 17:50:32 +03:00
Oleksii Holub 5405e619a9 Set application name to fix macOS app menu title (#1405) 2025-06-26 00:35:32 +03:00
Tyrrrz adafaaa49d Clean up chmods 2025-06-25 19:27:26 +03:00
Oleksii Holub d071459a5b Fix crash when exporting multiple channels to drive root (#1399) 2025-06-16 20:38:10 +03:00
Tyrrrz cc92b2cbe6 More cleanup 2025-06-08 22:53:10 +03:00
Tyrrrz 08718425f1 Clean up 2025-06-08 22:37:24 +03:00
Tyrrrz 540ba34fb4 Rename solution items 2025-06-08 21:25:01 +03:00
Tyrrrz 22134108a4 Apply new formatting 2025-06-08 21:17:10 +03:00
dependabot[bot] 82499af25a Bump CliFx and 9 others (#1389)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-08 21:13:22 +03:00
Rimi Kanokawa 4a74ff8a02 Make the names assigned to unnamed group DMs predictable (#1386) 2025-06-02 02:01:15 +03:00
Mark Cilia Vincenti 7dfcf5ac19 Change InvalidFileNameChars to use FrozenSet instead of HashSet (#1384) 2025-06-02 01:48:25 +03:00
dependabot[bot] 19c5fb40b2 Bump codecov/codecov-action from 5.4.2 to 5.4.3 in the actions group (#1388)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 01:40:30 +03:00
audinowho d4fa8f0954 Support --include-threads in the export command (#1343) 2025-05-27 23:37:54 +03:00
Oleksii Holub 0d9168e0d2 Include notes about running the app on macOS in readme 2025-05-22 21:16:13 +03:00
Tyrrrz aa8ace8389 Streamline dev build dialog 2025-05-13 19:28:40 +03:00
Tyrrrz 97cb8e2347 Allow snackbar to show multiple elements 2025-05-13 19:26:35 +03:00
Tyrrrz 92ee97a5da Clean up 2025-05-12 23:22:54 +03:00
Oleksii Holub 612ae2e894 Add a setting to control whether to respect advisory rate limits (#1342) 2025-05-12 19:52:47 +03:00
Tyrrrz 1506afc4a2 $ErrorActionPreference 2025-05-11 01:33:43 +03:00
Oleksii Holub b56c7db3ec Create MacOS app bundle via a project target (#1376) 2025-05-11 01:29:55 +03:00
Yudi 59d803d9f1 Bundle the app for macOS (#1375) 2025-05-10 03:03:55 +03:00
Yudi 30ba273fb1 Update bot token docs (#1371)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2025-05-04 19:49:42 +03:00
Tyrrrz f9fa35b6ad Downgrade CSharpier 2025-05-03 23:40:59 +03:00
dependabot[bot] 7d2ebb04de Bump the nuget group across 1 directory with 11 updates (#1374)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 23:40:26 +03:00
dependabot[bot] 1c9f4275b2 Bump the actions group with 2 updates (#1372)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 23:27:34 +03:00
Tyrrrz 1fadc0755b Refactor after last changes 2025-04-21 00:45:26 +03:00
masmc05 7ddd55951c Don't ignore next pages of archived threads 2025-04-20 00:25:35 +03:00
Tyrrrz 6fb197cf0b Refactor 2025-04-02 00:34:52 +03:00
Leonardo Mosquera 7add81a472 Don't consider it an error if there is nothing to export (#1349) 2025-04-02 00:14:35 +03:00
dependabot[bot] cf7580014c Bump the nuget group across 1 directory with 12 updates (#1361)
Bumps the nuget group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [FluentAssertions](https://github.com/fluentassertions/fluentassertions) | `8.1.1` | `8.2.0` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration.EnvironmentVariables](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration.UserSecrets](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [RazorBlade](https://github.com/ltrzesniewski/RazorBlade) | `0.8.0` | `0.9.0` |
| [AngleSharp](https://github.com/AngleSharp/AngleSharp) | `1.2.0` | `1.2.0` |
| [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) | `6.5.3` | `6.5.4` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia.Desktop](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia.Diagnostics](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Microsoft.Extensions.DependencyInjection](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Onova](https://github.com/Tyrrrz/Onova) | `2.6.12` | `2.6.13` |



Updates `FluentAssertions` from 8.1.1 to 8.2.0
- [Release notes](https://github.com/fluentassertions/fluentassertions/releases)
- [Changelog](https://github.com/fluentassertions/fluentassertions/blob/main/AcceptApiChanges.ps1)
- [Commits](https://github.com/fluentassertions/fluentassertions/compare/8.1.1...8.2.0)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration.EnvironmentVariables` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration.UserSecrets` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `RazorBlade` from 0.8.0 to 0.9.0
- [Release notes](https://github.com/ltrzesniewski/RazorBlade/releases)
- [Commits](https://github.com/ltrzesniewski/RazorBlade/compare/v0.8.0...v0.9.0)

Updates `AngleSharp` from 1.2.0 to 1.2.0
- [Release notes](https://github.com/AngleSharp/AngleSharp/releases)
- [Changelog](https://github.com/AngleSharp/AngleSharp/blob/devel/CHANGELOG.md)
- [Commits](https://github.com/AngleSharp/AngleSharp/compare/1.2.0...1.2.0)

Updates `YoutubeExplode` from 6.5.3 to 6.5.4
- [Release notes](https://github.com/Tyrrrz/YoutubeExplode/releases)
- [Commits](https://github.com/Tyrrrz/YoutubeExplode/compare/6.5.3...6.5.4)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia.Desktop` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia.Diagnostics` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Microsoft.Extensions.DependencyInjection` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Onova` from 2.6.12 to 2.6.13
- [Release notes](https://github.com/Tyrrrz/Onova/releases)
- [Commits](https://github.com/Tyrrrz/Onova/compare/2.6.12...2.6.13)

---
updated-dependencies:
- dependency-name: FluentAssertions
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.EnvironmentVariables
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.UserSecrets
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: RazorBlade
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: AngleSharp
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: YoutubeExplode
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Desktop
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Diagnostics
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.DependencyInjection
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Onova
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:58:39 +03:00
dependabot[bot] 606c082759 Bump the actions group with 3 updates (#1360)
Bumps the actions group with 3 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/setup-dotnet](https://github.com/actions/setup-dotnet) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/upload-artifact` from 4.6.1 to 4.6.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1...ea165f8d65b6e75b540449e92b4886f43607fa02)

Updates `actions/setup-dotnet` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/3951f0dfe7a07e2313ec93c75700083e2005cbab...67a3573c9a986a3f9c594539f4ab511d57bb3ce9)

Updates `actions/download-artifact` from 4.1.9 to 4.2.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/cc203385981b70ca67e1cc392babf9cc229d5806...95815c38cf2ff2164869cbab79da8d1f422bc89e)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:35:39 +03:00
Tyrrrz 5bbb706b3c Fix nullref warning 2025-03-18 20:08:18 +02:00
Tyrrrz deca3fc1bf Use partial properties 2025-03-18 20:06:44 +02:00
Tyrrrz fca6729ef0 Use Uri.EscapeDataString in a few others places 2025-03-18 19:35:24 +02:00
Lucas LaBuff 6f877cf543 Revert to Uri.EscapeDataString when encoding asset URIs (#1355) 2025-03-18 19:10:21 +02:00
Oleksii Holub 62babc0de5 Don't attempt to infer asset timestamp from message timestamp (#1352)
This is inaccurate and somewhat works only for attachments anyway. Not worth the overhead.
2025-03-13 21:43:19 +02:00
Tyrrrz 9e115562dc Use WebUtility.UrlEncode instead of Uri.EscapeDataString 2025-03-11 21:48:02 +02:00
Oleksii Holub db50a2bb96 Fix file path encoding edge cases in HTML export (#1351) 2025-03-10 19:11:17 +02:00
dependabot[bot] b39d015133 Bump the nuget group with 15 updates (#1347)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 21:42:10 +02:00
dependabot[bot] b4c3dce760 Bump the actions group with 4 updates (#1346)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 21:41:06 +02:00
Tyrrrz f31e73bb7a Unify whitespace characters to fix tests 2025-02-04 00:17:57 +02:00
Tyrrrz 75ff5c2f7e Improve the format workflow job 2025-02-03 23:48:02 +02:00
dependabot[bot] 7663f8ed31 Bump the nuget group with 14 updates (#1340)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 21:50:25 +02:00
dependabot[bot] c0e7334084 Bump the actions group with 3 updates (#1341)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 21:42:16 +02:00
Tyrrrz a9eca1ceb2 Formatting 2025-01-01 16:22:08 +02:00
dependabot[bot] 45ff1daff3 Bump the nuget group with 12 updates (#1329)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 13:13:45 +02:00
dependabot[bot] 5299c2ebc4 Bump the actions group with 4 updates (#1330)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 13:11:37 +02:00
Jason N. White f6c189f57c Update copyright license year (#1328) 2025-01-01 13:11:26 +02:00
Ritiek Malhotra a9acf17375 Prefer message timestamp over the last-modified header for asset file dates (#1321) 2024-12-12 19:55:44 +02:00
dependabot[bot] bf417db80c Bump the nuget group with 15 updates (#1318)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-01 18:51:14 +02:00
dependabot[bot] d78d22d066 Bump codecov/codecov-action from 4.6.0 to 5.0.7 in the actions group (#1319)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-01 18:50:14 +02:00
Tyrrrz 0fa5e93f0d Use .NET 9 2024-11-12 22:19:58 +02:00
Tyrrrz 7b892c8e5b Fix some text alignment issues with the new version of Avalonia 2024-11-07 18:46:32 +02:00
Oleksii Holub 692d886442 Remove obsolete/no-op ServicePoint references/calls (#1314) 2024-11-06 20:55:04 +02:00
Tyrrrz e664eab5fb Fix recursive initialization issue 2024-11-06 20:16:53 +02:00
Tyrrrz 1fb6156187 Refactor 2024-11-06 19:36:34 +02:00
Oleksii Holub 789e5af8ba Include inline emoji in JSON export (#1311) 2024-11-06 19:23:40 +02:00
dependabot[bot] 9c15baf799 Bump the actions group with 5 updates (#1312)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-02 00:47:52 +02:00
dependabot[bot] 56fa6ee150 Bump the nuget group with 7 updates (#1313)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-02 00:47:21 +02:00
Tyrrrz 09e0b3f133 Format stuff 2024-10-26 21:41:16 +03:00
Tyrrrz e8192b2b53 Improve wording 2024-10-23 23:54:30 +03:00
LostXOR 34e9b0bfcc Add info for --filter in CLI docs (#1310) 2024-10-23 03:44:42 +03:00
Kaden abd7adc9e0 Add %d template token to CLI docs (#1306) 2024-10-14 13:37:25 +03:00
dependabot[bot] f5cdd5c8be Bump the nuget group with 8 updates (#1302)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 21:10:21 +03:00
dependabot[bot] 9fb78509b4 Bump actions/checkout from 4.1.7 to 4.2.0 in the actions group (#1303)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 21:10:13 +03:00
Andrew Low 161e91ccf4 Add missing tzdata package to docker build (#1296) 2024-09-23 05:55:47 +03:00
dependabot[bot] 8519ec0fff Bump the nuget group with 9 updates (#1283)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 19:05:04 +03:00
dependabot[bot] 9f8f718c94 Bump actions/upload-artifact from 4.3.4 to 4.4.0 in the actions group (#1282)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 19:03:41 +03:00
Mark Cilia Vincenti bb14e3431d Bump AsyncKeyedLock to 7.0.1 (#1276) 2024-08-26 17:23:29 +03:00
Tyrrrz aa377f3131 Don't use AppDomain 2024-08-18 19:03:22 +03:00
Oleksii Holub 522789e01d Limit recursion depth in markdown parser to prevent stack overflow (#1273) 2024-08-14 23:52:03 +03:00
Tyrrrz 10adba3a4d Format schformat 2024-08-11 00:10:23 +03:00
Tyrrrz 73e2e0624b Use a few new APIs 2024-08-10 22:28:07 +03:00
Tyrrrz c978a4cf60 Fix popup background 2024-08-01 23:22:03 +03:00
dependabot[bot] 6d82930505 Bump the actions group with 4 updates (#1267)
Bumps the actions group with 4 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/setup-dotnet](https://github.com/actions/setup-dotnet) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `docker/setup-buildx-action` from 3.3.0 to 3.6.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/d70bba72b1f3fd22344832f00baa16ece964efeb...988b5a0280414f521da01fcc63a27aeeb4b104db)

Updates `actions/upload-artifact` from 4.3.3 to 4.3.4
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/65462800fd760344b1a7b4382951275a0abb4808...0b2256b8c012f0828dc542b3febcab082c67f72b)

Updates `actions/setup-dotnet` from 4.0.0 to 4.0.1
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/4d6c8fcf3c8f7a60068d26b594648e99df24cee3...6bd8b7f7774af54e05809fcc5431931b3eb1ddee)

Updates `actions/download-artifact` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/65a9edc5881444af0b9093a5e628f2fe47ea3b2e...fa0a91b85d4f404e444e00e005971372dc801d16)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-01 22:56:18 +03:00
dependabot[bot] 4e98c79313 Bump the nuget group with 8 updates (#1268)
Bumps the nuget group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [xunit](https://github.com/xunit/xunit) | `2.8.1` | `2.9.0` |
| [xunit.runner.visualstudio](https://github.com/xunit/visualstudio.xunit) | `2.8.1` | `2.8.2` |
| [AsyncKeyedLock](https://github.com/MarkCiliaVincenti/AsyncKeyedLock) | `6.4.2` | `7.0.0` |
| [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) | `6.3.16` | `6.4.0` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.1` |
| [Avalonia.Desktop](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.1` |
| [Avalonia.Diagnostics](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.1` |
| [Material.Avalonia](https://github.com/AvaloniaCommunity/Material.Avalonia) | `3.6.0` | `3.7.2` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.0` |


Updates `xunit` from 2.8.1 to 2.9.0
- [Commits](https://github.com/xunit/xunit/compare/2.8.1...2.9.0)

Updates `xunit.runner.visualstudio` from 2.8.1 to 2.8.2
- [Release notes](https://github.com/xunit/visualstudio.xunit/releases)
- [Commits](https://github.com/xunit/visualstudio.xunit/compare/2.8.1...2.8.2)

Updates `AsyncKeyedLock` from 6.4.2 to 7.0.0
- [Release notes](https://github.com/MarkCiliaVincenti/AsyncKeyedLock/releases)
- [Commits](https://github.com/MarkCiliaVincenti/AsyncKeyedLock/compare/6.4.2...7.0.0)

Updates `YoutubeExplode` from 6.3.16 to 6.4.0
- [Release notes](https://github.com/Tyrrrz/YoutubeExplode/releases)
- [Commits](https://github.com/Tyrrrz/YoutubeExplode/compare/6.3.16...6.4)

Updates `Avalonia` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia.Desktop` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia.Diagnostics` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Material.Avalonia` from 3.6.0 to 3.7.2
- [Release notes](https://github.com/AvaloniaCommunity/Material.Avalonia/releases)
- [Commits](https://github.com/AvaloniaCommunity/Material.Avalonia/compare/v3.6.0...v3.7.2)

Updates `Avalonia` from 11.0.11 to 11.1.0
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

---
updated-dependencies:
- dependency-name: xunit
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: xunit.runner.visualstudio
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: AsyncKeyedLock
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: nuget
- dependency-name: YoutubeExplode
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia.Desktop
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia.Diagnostics
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Material.Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-01 22:55:58 +03:00
Tyrrrz 42e6de359e Invert WatchProperty(...) methods' watchInitialValue 2024-07-19 02:01:00 +03:00
dependabot[bot] 281e0f608a Bump the actions group with 2 updates (#1256)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 18:27:02 +03:00
dependabot[bot] c63313e328 Bump the nuget group with 7 updates (#1255)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 18:26:47 +03:00
LostXOR 767ca1b177 Remove unnecessary prefixes from CLI docs (#1254)
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2024-06-30 01:37:15 +03:00
Oleksii Holub 429801183c Warn when using development build (#1248) 2024-06-01 00:58:24 +03:00
Tyrrrz 520c06dceb Clean up 2024-06-01 00:36:24 +03:00
dependabot[bot] 46d8deba87 Bump the nuget group with 3 updates (#1247)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-31 03:29:50 +03:00
dependabot[bot] 0e13be2fdf Bump the actions group with 6 updates (#1246)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-31 03:29:08 +03:00
Oleksii Holub 15f8a13f02 Create dependabot.yml 2024-05-31 03:23:48 +03:00
Oleksii Holub c8ea365c04 Strip symbols (#1242) 2024-05-28 00:57:11 +03:00
Oleksii Holub 949abc2582 Fix --partition and --filter options not working due to assembly trimming (#1240) 2024-05-26 23:12:05 +03:00
Tyrrrz 3b3423dd9b Clean up platform checks in update service 2024-05-24 01:28:35 +03:00
Tyrrrz 8380e88744 Consistent platform checks 2024-05-23 18:03:52 +03:00
Tyrrrz f1bc5a2266 Remove NoWarn in the CLI project 2024-05-23 17:23:49 +03:00
139 changed files with 11320 additions and 10649 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ The HTML format replicates Discord's interface, making it the most user-friendly
It's the best format for attachment preview and sharing. It's the best format for attachment preview and sharing.
You can open `.html` files with a web browser, such as Google Chrome. You can open `.html` files with a web browser, such as Google Chrome.
> **Warning**: > [!WARNING]
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed. > If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
> Export using the "Download assets" (`--media`) option to avoid this. > Export using the "Download assets" (`--media`) option to avoid this.
+56 -11
View File
@@ -1,10 +1,11 @@
# Obtaining Token and Channel IDs # Obtaining Token and Channel IDs
> **Warning**: > [!WARNING]
> **Do not share your token!** > **Do not share your token!** A token gives full access to an account.
> A token gives full access to an account. To reset a user token, change your account password. To reset a bot token, click on [Regenerate](#how-to-get-a-bot-token) in the bot settings. > To reset a user token, change your account password.
> To reset a bot token, click on [Reset Token](#how-to-export-with-a-bot-token) in the bot settings.
## How to get a User Token ## How to get a user token
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk. **Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
@@ -26,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
3. Type 3. Type
```js ```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken() let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
``` ```
into the console and press <kbd>Enter</kbd>. The console will display your user token. into the console and press <kbd>Enter</kbd>. The console will display your user token.
@@ -119,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
1. Type 1. Type
```js ```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken() let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
``` ```
into the console and press <kbd>Enter</kbd>. The console will display your user token. into the console and press <kbd>Enter</kbd>. The console will display your user token.
@@ -254,17 +255,61 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
4. Press <kbd>Esc</kbd>. The settings page will close. 4. Press <kbd>Esc</kbd>. The settings page will close.
5. To find your user token, continue [here](#in-chrome). 5. To find your user token, continue [here](#in-chrome).
## How to get a Bot Token ## How to export with a bot token
### Step 1 - Create an application
You can create a new application or use an existing one. If you want to create a new one:
1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Click on **New Application** in the top right corner
3. Enter a name for your application and click **Create**
### Step 2 - Invite the bot to your server
The bot needs to be invited to the server you'd like to export from.
1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Navigate to **General Information** on the left
3. Copy the **Application ID**
4. Open the following URL in your browser, replacing `YOUR_APP_ID` with the copied Client ID:
<!-- Permission code 66560 corresponds to "View Channels" and "Read Message History" permissions.
User can uncheck these when adding the bot to their server. -->
```
https://discord.com/oauth2/authorize?scope=bot&permissions=66560&client_id=YOUR_APP_ID
```
### Step 3 - Ensure message content intent is enabled
If this option is not enabled, the exported files will be empty.
1. Go to [Discord developer portal](https://discord.com/developers/applications) 1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Open your Application's settings 2. Open your Application's settings
3. Navigate to the **Bot** section on the left 3. Navigate to the **Bot** section on the left
4. Under **Token** click **Copy** 4. Scroll down to the **Privileged Gateway Intents** section
5. Enable **Message Content Intent** by toggling the switch
> **Warning**: <img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
![https://discord.com/developers/applications/](https://i.imgur.com/BdrrxlY.png) ### Step 4 - Copy the bot token
If you don't have a bot token yet or if you've lost it, follow these steps to reset it:
1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Open your Application's settings
3. Navigate to the **Bot** section on the left
4. Under **Token** click **Reset Token**
5. Click **Yes, do it!** and authenticate to confirm
> **Tip**:
> As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.
> [!WARNING]
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
![https://discord.com/developers/applications/](https://i.imgur.com/soiB8Qc.png)
--- ---
+7 -1
View File
@@ -24,7 +24,7 @@ Follow the [instructions here](Token-and-IDs.md).
### Will I get banned if I use this? ### Will I get banned if I use this?
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bots) don't have this restriction. Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bot-users) don't have this restriction.
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person? ### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
@@ -146,6 +146,12 @@ Red Hat: `cert-sync --user /etc/pki/tls/certs/ca-bundle.crt`
If it still doesn't work, try mozroots: `mozroots --import --ask-remove` If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
## macOS-specific
### DiscordChatExporter is damaged and cant be opened. You should move it to the Trash.
Check the [Using the GUI page](Using-the-GUI.md#step-1) for instructions on how to run the app.
--- ---
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new). > ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
+44 -30
View File
@@ -2,7 +2,7 @@
## Step 1 ## Step 1
After extracting the `.zip`, open Command Prompt, aka `cmd` (`Terminal` on **macOS** and **Linux**). After extracting the `.zip` archive, open your preferred terminal.
## Step 2 ## Step 2
@@ -18,15 +18,18 @@ You can also drag and drop the folder on **every platform**.
## Step 3 ## Step 3
Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file Now we're ready to run the commands.
paths according to your system.
Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options. Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options.
```console ```console
$ ./DiscordChatExporter.Cli ./DiscordChatExporter.Cli
``` ```
> **Note**:
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md). > **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## CLI commands ## CLI commands
@@ -47,13 +50,13 @@ To use the commands, you'll need a token. For the instructions on how to get a t
To get help with a specific command, run: To get help with a specific command, run:
```console ```console
$ ./DiscordChatExporter.Cli command --help ./DiscordChatExporter.Cli command --help
``` ```
For example, to figure out how to use the `export` command, run: For example, to figure out how to use the `export` command, run:
```console ```console
$ ./DiscordChatExporter.Cli export --help ./DiscordChatExporter.Cli export --help
``` ```
## Export a specific channel ## Export a specific channel
@@ -61,7 +64,7 @@ $ ./DiscordChatExporter.Cli export --help
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`. You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
``` ```
#### Changing the format #### Changing the format
@@ -70,7 +73,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
format is `HtmlDark`. format is `HtmlDark`.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
``` ```
#### Changing the output filename #### Changing the output filename
@@ -78,7 +81,7 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
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 ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
``` ```
#### Changing the output directory #### Changing the output directory
@@ -88,7 +91,7 @@ extension.
If any of the folders in the path have a space in its name, escape them with quotes ("). If any of the folders in the path have a space in its name, escape them with quotes (").
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
``` ```
#### Changing the filename and output directory #### Changing the filename and output directory
@@ -98,7 +101,7 @@ Note that the filename must have an extension, otherwise it will be considered a
If any of the folders in the path have a space in its name, escape them with quotes ("). If any of the folders in the path have a space in its name, escape them with quotes (").
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
``` ```
#### Generating the filename and output directory dynamically #### Generating the filename and output directory dynamically
@@ -106,7 +109,7 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports
You can use template tokens to generate the output file path based on the server and channel metadata. You can use template tokens to generate the output file path based on the server and channel metadata.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
``` ```
Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
@@ -125,6 +128,7 @@ Here is the full list of supported template tokens:
- `%P` - category position - `%P` - category position
- `%a` - the "after" date - `%a` - the "after" date
- `%b` - the "before" date - `%b` - the "before" date
- `%d` - the current date
- `%%` - escapes `%` - `%%` - escapes `%`
#### Partitioning #### Partitioning
@@ -133,13 +137,13 @@ You can use partitioning to split files after a given number of messages or file
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files. For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10 ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
``` ```
A 45 MB channel set to be partitioned every 20 MB will output 3 files. A 45 MB channel set to be partitioned every 20 MB will output 3 files.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
``` ```
#### Downloading assets #### Downloading assets
@@ -150,7 +154,7 @@ downloaded when using the plain text (TXT) export format.
A folder containing the assets will be created along with the exported chat. They must be kept together. A folder containing the assets will be created along with the exported chat. They must be kept together.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
``` ```
#### Reusing assets #### Reusing assets
@@ -159,7 +163,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
same folder. Using this option can speed up future exports. This option requires the `--media` option. same folder. Using this option can speed up future exports. This option requires the `--media` option.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
``` ```
#### Changing the media directory #### Changing the media directory
@@ -168,7 +172,7 @@ By default, the media directory is created alongside the exported chat. You can
providing a path that ends with a slash. All of the exported media will be stored in this directory. providing a path that ends with a slash. All of the exported media will be stored in this directory.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
``` ```
#### Changing the date format #### Changing the date format
@@ -177,7 +181,7 @@ You can customize how dates are formatted in the exported files by using `--loca
locales. The default locale is `en-US`. locales. The default locale is `en-US`.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
``` ```
#### Date ranges #### Date ranges
@@ -186,14 +190,14 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
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 ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18 ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
``` ```
**Messages sent after a date** **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 ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
``` ```
**Messages sent in a date range** **Messages sent in a date range**
@@ -201,7 +205,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
September 17th, 2019 11:34 PM and September 18th: September 17th, 2019 11:34 PM and September 18th:
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
``` ```
You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
@@ -210,12 +214,22 @@ Don't forget to quote (") the date if it has spaces!
More info about .NET date More info about .NET date
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings). formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
#### Filtering messages
Use `--filter` to filter what messages are included in the export.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
```
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md).
### Export channels from a specific server ### Export channels from a specific server
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option: To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
```console ```console
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
``` ```
#### Including threads #### Including threads
@@ -225,7 +239,7 @@ specifying which threads should be included. It has possible values of `none`, `
threads should be included. To include both active and archived threads, use `--include-threads all`. threads should be included. To include both active and archived threads, use `--include-threads all`.
```console ```console
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
``` ```
#### Including voice channels #### Including voice channels
@@ -235,7 +249,7 @@ specifying whether to include voice channels in the export. It has possible valu
voice channels, use `--include-vc false`. voice channels, use `--include-vc false`.
```console ```console
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
``` ```
### Export all channels ### Export all channels
@@ -243,7 +257,7 @@ $ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc fals
To export all accessible channels, use the `exportall` command: To export all accessible channels, use the `exportall` command:
```console ```console
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
``` ```
#### Excluding DMs #### Excluding DMs
@@ -251,7 +265,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
To exclude DMs, add the `--include-dm false` option. To exclude DMs, add the `--include-dm false` option.
```console ```console
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
``` ```
### List channels in a server ### List channels in a server
@@ -259,7 +273,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option: To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
```console ```console
$ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814 ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
``` ```
### List direct message channels ### List direct message channels
@@ -267,7 +281,7 @@ $ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
To list all DM channels accessible to the current account, use the `dm` command: To list all DM channels accessible to the current account, use the `dm` command:
```console ```console
$ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn" ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
``` ```
### List servers ### List servers
@@ -275,5 +289,5 @@ $ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
To list all servers accessible by the current account, use the `guilds` command: To list all servers accessible by the current account, use the `guilds` command:
```console ```console
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
``` ```
+17 -2
View File
@@ -10,13 +10,28 @@
### Step 1 ### Step 1
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux). 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 skip these steps, the "DiscordChatExporter is damaged and cant 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").
2. Paste the following into the terminal window:
```bash
xattr -rd com.apple.quarantine
```
3. Hit <kbd>Space</kbd> once to add a space after the command
4. Drag and drop DiscordChatExporter.app into the terminal window
5. Press <kbd>Return</kbd> to run the command
6. Open DiscordChatExporter.app normally
> Apple requires apps to be notarized and signed in order to run on macOS without warnings, which in turn requires an Apple Developer membership ($99/year). This open-source project is distributed for free and without commercial intent.
### Step 2 ### Step 2
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→). Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
> **Warning**: > [!WARNING]
> **Never share your token!** > **Never share your token!**
> A token gives full access to an account, treat it like a password. > A token gives full access to an account, treat it like a password.
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
labels:
- enhancement
groups:
actions:
patterns:
- "*"
- package-ecosystem: docker
directory: "/"
schedule:
interval: monthly
labels:
- enhancement
groups:
docker:
patterns:
- "*"
- package-ecosystem: nuget
directory: "/"
schedule:
interval: monthly
labels:
- enhancement
groups:
nuget:
patterns:
- "*"
+6 -6
View File
@@ -23,10 +23,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image - name: Build image
run: > run: >
@@ -36,8 +36,8 @@ jobs:
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }} --build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar --output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload artifacts - name: Upload image
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: DiscordChatExporter.Cli.Docker name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar path: DiscordChatExporter.Cli.Docker.tar
@@ -55,10 +55,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to DockerHub - name: Login to DockerHub
run: > run: >
+32 -20
View File
@@ -26,18 +26,29 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
with:
dotnet-version: 8.0.x # Build the project separately to discern between build and format errors
- name: Build
run: >
dotnet build
-p:CSharpier_Bypass=true
--configuration Release
- name: Verify formatting - name: Verify formatting
id: verify
run: > run: >
dotnet build dotnet build
-t:CSharpierFormat -t:CSharpierFormat
--configuration Release --configuration Release
--no-restore
- name: Report issues
if: ${{ failure() && steps.verify.outcome == 'failure' }}
run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them."
test: test:
# Tests need access to secrets, so we can't run them against PRs because of limited trust # Tests need access to secrets, so we can't run them against PRs because of limited trust
@@ -51,12 +62,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
with:
dotnet-version: 8.0.x
- name: Run tests - name: Run tests
env: env:
@@ -72,7 +81,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -108,25 +117,24 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
with:
dotnet-version: 8.0.x
- name: Publish app - name: Publish app
run: > run: >
dotnet publish ${{ matrix.app }} dotnet publish ${{ matrix.app }}
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }} -p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
-p:CSharpier_Bypass=true -p:CSharpier_Bypass=true
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
--output ${{ matrix.app }}/bin/publish/ --output ${{ matrix.app }}/bin/publish/
--configuration Release --configuration Release
--runtime ${{ matrix.rid }} --runtime ${{ matrix.rid }}
--self-contained --self-contained
- name: Upload artifacts - name: Upload app binaries
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: ${{ matrix.asset }}.${{ matrix.rid }} name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/ path: ${{ matrix.app }}/bin/publish/
@@ -190,15 +198,19 @@ jobs:
contents: write contents: write
steps: steps:
- name: Download artifacts - name: Download app binaries
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: ${{ matrix.asset }}.${{ matrix.rid }} name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/ path: ${{ matrix.app }}/
- name: Set permissions - name: Set permissions
if: ${{ !startsWith(matrix.rid, 'win') }} if: ${{ !startsWith(matrix.rid, 'win-') }}
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }} run: |
[ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true
# macOS bundle
[ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true
- name: Create package - name: Create package
# Change into the artifacts directory to avoid including the directory itself in the zip archive # Change into the artifacts directory to avoid including the directory itself in the zip archive
@@ -223,7 +235,7 @@ jobs:
steps: steps:
- name: Notify Discord - name: Notify Discord
uses: tyrrrz/action-http-request@64c70c67f5ebc54d4c7ea09cbe3553322778afd5 # 1.1.2 uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
with: with:
url: ${{ secrets.DISCORD_WEBHOOK }} url: ${{ secrets.DISCORD_WEBHOOK }}
method: POST method: POST
+1 -1
View File
@@ -9,4 +9,4 @@ bin/
obj/ obj/
# Test results # Test results
TestResults/ TestResults/
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>999.9.9-dev</Version> <Version>999.9.9-dev</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
@@ -11,22 +10,24 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.2" /> <PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> <PackageReference
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" /> Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" /> Version="10.0.2"
<PackageReference Include="xunit" Version="2.8.0" /> />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" /> <ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
</ItemGroup> </ItemGroup>
</Project>
</Project>
@@ -10,6 +10,8 @@ public static class ChannelIds
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687"); public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636");
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842"); public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020"); public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
@@ -19,12 +19,7 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
public static class ExportWrapper public static class ExportWrapper
{ {
private static readonly AsyncKeyedLocker<string> Locker = private static readonly AsyncKeyedLocker<string> Locker = new();
new(o =>
{
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
private static readonly string DirPath = Path.Combine( private static readonly string DirPath = Path.Combine(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
@@ -61,7 +56,7 @@ public static class ExportWrapper
ExportFormat = format, ExportFormat = format,
OutputPath = filePath, OutputPath = filePath,
Locale = "en-US", Locale = "en-US",
IsUtcNormalizationEnabled = true IsUtcNormalizationEnabled = true,
}.ExecuteAsync(console); }.ExecuteAsync(console);
} }
+1 -1
View File
@@ -8,5 +8,5 @@ In order to run these tests locally, you need to join the test server and config
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>` 3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
4. Run the tests: `dotnet test` 4. Run the tests: `dotnet test`
> **Note**: > [!NOTE]
> If you want to add a new test case, please let me know and I will give you the required permissions on the server. > If you want to add a new test case, please let me know and I will give you the required permissions on the server.
@@ -30,7 +30,7 @@ public class DateRangeSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
After = Snowflake.FromDate(after) After = Snowflake.FromDate(after),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -50,7 +50,7 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero) new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
], ],
o => o =>
o.Using<DateTimeOffset>(ctx => o.Using<DateTimeOffset>(ctx =>
@@ -74,7 +74,7 @@ public class DateRangeSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before) Before = Snowflake.FromDate(before),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -92,7 +92,7 @@ public class DateRangeSpecs
[ [
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero), new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero), new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero) new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero),
], ],
o => o =>
o.Using<DateTimeOffset>(ctx => o.Using<DateTimeOffset>(ctx =>
@@ -118,7 +118,7 @@ public class DateRangeSpecs
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before), Before = Snowflake.FromDate(before),
After = Snowflake.FromDate(after) After = Snowflake.FromDate(after),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -137,7 +137,7 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero) new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
], ],
o => o =>
o.Using<DateTimeOffset>(ctx => o.Using<DateTimeOffset>(ctx =>
@@ -146,4 +146,31 @@ public class DateRangeSpecs
.WhenTypeIs<DateTimeOffset>() .WhenTypeIs<DateTimeOffset>()
); );
} }
[Fact]
public async Task I_can_filter_the_export_to_not_include_any_messages()
{
// Arrange
var before = new DateTimeOffset(2020, 08, 01, 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,
Before = Snowflake.FromDate(before),
}.ExecuteAsync(new FakeConsole());
// Assert
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages")
.EnumerateArray()
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
.ToArray();
timestamps.Should().BeEmpty();
}
} }
@@ -29,7 +29,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("some text") MessageFilter = MessageFilter.Parse("some text"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -54,7 +54,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("from:Tyrrrz") MessageFilter = MessageFilter.Parse("from:Tyrrrz"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -79,7 +79,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:image") MessageFilter = MessageFilter.Parse("has:image"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -104,7 +104,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:pin") MessageFilter = MessageFilter.Parse("has:pin"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -129,7 +129,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:invite") MessageFilter = MessageFilter.Parse("has:invite"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -154,7 +154,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz") MessageFilter = MessageFilter.Parse("mentions:Tyrrrz"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
); );
// Assert // Assert
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src"); // Check that the YouTube video thumbnail image exists with the correct video ID
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE"); var youtubeThumbnailSrc = message
.QuerySelectorAll("img")
.Select(e => e.GetAttribute("src"))
.WhereNotNull()
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
youtubeThumbnailSrc.Should().NotBeNull();
} }
[Fact] [Fact]
@@ -28,7 +28,7 @@ public class HtmlGroupingSpecs
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.GroupingTestCases], ChannelIds = [ChannelIds.GroupingTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = file.Path OutputPath = file.Path,
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -1,6 +1,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@@ -19,8 +20,13 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM"); message
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); .Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Default timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -33,8 +39,8 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Short time timestamp: 1:36 PM"); message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -47,8 +53,8 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Long time timestamp: 1:36:12 PM"); message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -61,8 +67,8 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Short date timestamp: 2/12/2023"); message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -75,8 +81,13 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023"); message
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); .Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Long date timestamp: Sunday, February 12, 2023");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -89,8 +100,13 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM"); message
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); .Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -105,9 +121,11 @@ public class HtmlMarkdownSpecs
// Assert // Assert
message message
.Text() .Text()
.ReplaceWhiteSpace()
.Should() .Should()
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM"); .Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -120,8 +138,13 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM"); message
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); .Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
} }
[Fact] [Fact]
@@ -0,0 +1,69 @@
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonEmojiSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("866768521052553216")
);
// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(4);
inlineEmojis[0].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[0].GetProperty("name").GetString().Should().Be("🙂");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("slight_smile");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[1].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[1].GetProperty("name").GetString().Should().Be("😦");
inlineEmojis[1].GetProperty("code").GetString().Should().Be("frowning");
inlineEmojis[1].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[1].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[2].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[2].GetProperty("name").GetString().Should().Be("😔");
inlineEmojis[2].GetProperty("code").GetString().Should().Be("pensive");
inlineEmojis[2].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[2].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[3].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[3].GetProperty("name").GetString().Should().Be("😂");
inlineEmojis[3].GetProperty("code").GetString().Should().Be("joy");
inlineEmojis[3].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[3].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("1299804867447230594")
);
// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(1);
inlineEmojis[0].GetProperty("id").GetString().Should().Be("754441880066064584");
inlineEmojis[0].GetProperty("name").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}
}
@@ -27,7 +27,7 @@ public class PartitioningSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("3") PartitionLimit = PartitionLimit.Parse("3"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -48,7 +48,7 @@ public class PartitioningSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("1kb") PartitionLimit = PartitionLimit.Parse("1kb"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -27,7 +27,7 @@ public class SelfContainedSpecs
ChannelIds = [ChannelIds.SelfContainedTestCases], ChannelIds = [ChannelIds.SelfContainedTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
ShouldDownloadAssets = true ShouldDownloadAssets = true,
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -0,0 +1,19 @@
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,7 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using PathEx = System.IO.Path;
namespace DiscordChatExporter.Cli.Tests.Utils; namespace DiscordChatExporter.Cli.Tests.Utils;
@@ -23,8 +22,8 @@ internal partial class TempDir
{ {
public static TempDir Create() public static TempDir Create()
{ {
var dirPath = PathEx.Combine( var dirPath = System.IO.Path.Combine(
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(), ?? Directory.GetCurrentDirectory(),
"Temp", "Temp",
Guid.NewGuid().ToString() Guid.NewGuid().ToString()
@@ -1,7 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using PathEx = System.IO.Path;
namespace DiscordChatExporter.Cli.Tests.Utils; namespace DiscordChatExporter.Cli.Tests.Utils;
@@ -23,15 +22,15 @@ internal partial class TempFile
{ {
public static TempFile Create() public static TempFile Create()
{ {
var dirPath = PathEx.Combine( var dirPath = System.IO.Path.Combine(
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(), ?? Directory.GetCurrentDirectory(),
"Temp" "Temp"
); );
Directory.CreateDirectory(dirPath); Directory.CreateDirectory(dirPath);
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp"); var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
return new TempFile(filePath); return new TempFile(filePath);
} }
+5 -2
View File
@@ -1,7 +1,7 @@
# -- Build # -- Build
# Specify the platform here so that we pull the SDK image matching the host platform, # Specify the platform here so that we pull the SDK image matching the host platform,
# instead of the target platform specified during build by the `--platform` option. # instead of the target platform specified during build by the `--platform` option.
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
# Expose the target architecture set by the `docker build --platform` option, so that # Expose the target architecture set by the `docker build --platform` option, so that
# we can build the assembly for the correct platform. # we can build the assembly for the correct platform.
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
# -- Run # -- Run
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly # Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS run FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS run
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli" LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file." LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
@@ -45,6 +45,9 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8
ENV LANG=en_US.UTF-8 ENV LANG=en_US.UTF-8
# Alpine is missing tzdata, which we need to support timezones
RUN apk add --no-cache tzdata
# Use a non-root user to ensure that the files shared with the host are accessible by the host user # Use a non-root user to ensure that the files shared with the host are accessible by the host user
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851 # https://github.com/Tyrrrz/DiscordChatExporter/issues/851
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174 # https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
@@ -1,4 +1,5 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
@@ -27,8 +28,19 @@ public abstract class DiscordCommandBase : ICommand
)] )]
public bool IsBotToken { get; init; } = false; public bool IsBotToken { get; init; } = false;
private DiscordClient? _discordClient; [CommandOption(
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token); "respect-rate-limits",
Description = "Whether to respect advisory rate limits. "
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
)]
public bool ShouldRespectRateLimits { get; init; } = true;
[field: AllowNull, MaybeNull]
protected DiscordClient Discord =>
field ??= new DiscordClient(
Token,
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
);
public virtual ValueTask ExecuteAsync(IConsole console) public virtual ValueTask ExecuteAsync(IConsole console)
{ {
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters; using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress; using Gress;
using Spectre.Console; using Spectre.Console;
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class ExportCommandBase : DiscordCommandBase public abstract class ExportCommandBase : DiscordCommandBase
{ {
private readonly string _outputPath = Directory.GetCurrentDirectory();
[CommandOption( [CommandOption(
"output", "output",
'o', 'o',
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string OutputPath public string OutputPath
{ {
get => _outputPath; get;
// Handle ~/ in paths on Unix systems // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _outputPath = Path.GetFullPath(value); init => field = Path.GetFullPath(value);
} } = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Export format.")] [CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark; public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null; public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
[CommandOption( [CommandOption(
"filter", "filter",
Description = "Only include messages that satisfy this filter. " Description = "Only include messages that satisfy this filter. "
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public bool ShouldReuseAssets { get; init; } = false; public bool ShouldReuseAssets { get; init; } = false;
private readonly string? _assetsDirPath;
[CommandOption( [CommandOption(
"media-dir", "media-dir",
Description = "Download assets to this directory. " Description = "Download assets to this directory. "
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string? AssetsDirPath public string? AssetsDirPath
{ {
get => _assetsDirPath; get;
// Handle ~/ in paths on Unix systems // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null; init => field = value is not null ? Path.GetFullPath(value) : null;
} }
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")] [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public bool IsUkraineSupportMessageDisabled { get; init; } = false; public bool IsUkraineSupportMessageDisabled { get; init; } = false;
private ChannelExporter? _channelExporter; [field: AllowNull, MaybeNull]
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
var cancellationToken = console.RegisterCancellationHandler();
// Asset reuse can only be enabled if the download assets option is set // Asset reuse can only be enabled if the download assets option is set
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425 // https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseAssets && !ShouldDownloadAssets) if (ShouldReuseAssets && !ShouldDownloadAssets)
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
throw new CommandException("Option --media-dir cannot be used without --media."); throw new CommandException("Option --media-dir cannot be used without --media.");
} }
var unwrappedChannels = new List<Channel>(channels);
// Unwrap threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync("Fetching threads...");
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetChannelThreadsAsync(
channels,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
unwrappedChannels.Add(thread);
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
fetchedThreadsCount++;
}
}
);
// Remove forums, as they cannot be exported directly and their constituent threads
// have already been fetched.
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
}
// Make sure the user does not try to export multiple channels into one file. // 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. // 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/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917 // https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath = var isValidOutputPath =
// Anything is valid when exporting a single channel // Anything is valid when exporting a single channel
channels.Count <= 1 unwrappedChannels.Count <= 1
// When using template tokens, assume the user knows what they're doing // When using template tokens, assume the user knows what they're doing
|| OutputPath.Contains('%') || OutputPath.Contains('%')
// Otherwise, require an existing directory or an unambiguous directory path // Otherwise, require an existing directory or an unambiguous directory path
@@ -177,10 +223,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
} }
// Export // Export
var cancellationToken = console.RegisterCancellationHandler();
var errorsByChannel = new ConcurrentDictionary<Channel, string>(); var errorsByChannel = new ConcurrentDictionary<Channel, string>();
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)..."); await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
await console await console
.CreateProgressTicker() .CreateProgressTicker()
.HideCompleted( .HideCompleted(
@@ -192,11 +238,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
.StartAsync(async ctx => .StartAsync(async ctx =>
{ {
await Parallel.ForEachAsync( await Parallel.ForEachAsync(
channels, unwrappedChannels,
new ParallelOptions new ParallelOptions
{ {
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit), MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
CancellationToken = cancellationToken CancellationToken = cancellationToken,
}, },
async (channel, innerCancellationToken) => async (channel, innerCancellationToken) =>
{ {
@@ -236,6 +282,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
} }
); );
} }
catch (ChannelEmptyException ex)
{
warningsByChannel[channel] = ex.Message;
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal) catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{ {
errorsByChannel[channel] = ex.Message; errorsByChannel[channel] = ex.Message;
@@ -248,10 +298,32 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
{ {
await console.Output.WriteLineAsync( await console.Output.WriteLineAsync(
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)." $"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
); );
} }
// Print warnings
if (warningsByChannel.Any())
{
await console.Output.WriteLineAsync();
using (console.WithForegroundColor(ConsoleColor.Yellow))
{
await console.Error.WriteLineAsync(
"Warnings reported for the following channel(s):"
);
}
foreach (var (channel, message) in warningsByChannel)
{
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
using (console.WithForegroundColor(ConsoleColor.Yellow))
await console.Error.WriteLineAsync(message);
}
await console.Error.WriteLineAsync();
}
// Print errors // Print errors
if (errorsByChannel.Any()) if (errorsByChannel.Any())
{ {
@@ -259,16 +331,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console.Error.WriteLineAsync( await console.Error.WriteLineAsync("Failed to export the following channel(s):");
$"Failed to export {errorsByChannel.Count} the following channel(s):"
);
} }
foreach (var (channel, error) in errorsByChannel) foreach (var (channel, message) in errorsByChannel)
{ {
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: "); await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
await console.Error.WriteLineAsync(error); await console.Error.WriteLineAsync(message);
} }
await console.Error.WriteLineAsync(); await console.Error.WriteLineAsync();
@@ -276,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Fail the command only if ALL channels failed to export. // Fail the command only if ALL channels failed to export.
// If only some channels failed to export, it's okay. // If only some channels failed to export, it's okay.
if (errorsByChannel.Count >= channels.Count) if (errorsByChannel.Count >= unwrappedChannels.Count)
throw new CommandException("Export failed."); throw new CommandException("Export failed.");
} }
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
{
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in channelIds)
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
// Unwrap categories
if (channel.IsCategory)
{
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel);
}
// Cache the guild channels to avoid redundant work
channelsByGuild[channel.GuildId] = guildChannels;
}
else
{
channels.Add(channel);
}
}
await ExportAsync(console, channels);
}
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
// Support Ukraine callout // Support Ukraine callout
@@ -5,8 +5,6 @@ using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Dump; using DiscordChatExporter.Core.Discord.Dump;
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true; public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
[CommandOption( [CommandOption(
"data-package", "data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. " Description = "Path to the personal data package (ZIP file) requested from Discord. "
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
); );
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s)."); await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
// Threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync(
$"Fetching threads for server '{guild.Name}'..."
);
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetGuildThreadsAsync(
guild.Id,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
channels.Add(thread);
ctx.Status(
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
);
fetchedThreadsCount++;
}
}
);
await console.Output.WriteLineAsync(
$"Fetched {fetchedThreadsCount} thread(s)."
);
}
} }
} }
// Pull from the data package // Pull from the data package
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
channels.RemoveAll(c => c.IsGuild); channels.RemoveAll(c => c.IsGuild);
if (!IncludeVoiceChannels) if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.IsVoice); channels.RemoveAll(c => c.IsVoice);
if (ThreadInclusionMode == ThreadInclusionMode.None)
channels.RemoveAll(c => c.IsThread);
if (ThreadInclusionMode != ThreadInclusionMode.All)
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
await ExportAsync(console, channels); await ExportAsync(console, channels);
} }
@@ -4,6 +4,8 @@ using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
await ExportAsync(console, ChannelIds);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in ChannelIds)
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
// Unwrap categories
if (channel.IsCategory)
{
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel);
}
// Cache the guild channels to avoid redundant work
channelsByGuild[channel.GuildId] = guildChannels;
}
else
{
channels.Add(channel);
}
}
await ExportAsync(console, channels);
} }
} }
@@ -3,8 +3,6 @@ using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true; public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>(); var channels = new List<Channel>();
// Regular channels
await console.Output.WriteLineAsync("Fetching channels..."); await console.Output.WriteLineAsync("Fetching channels...");
var fetchedChannelsCount = 0; var fetchedChannelsCount = 0;
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s)."); await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
// Threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync("Fetching threads...");
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetGuildThreadsAsync(
GuildId,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
channels.Add(thread);
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
fetchedThreadsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
}
await ExportAsync(console, channels); await ExportAsync(console, channels);
} }
} }
@@ -34,10 +34,17 @@ public class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get the token for your bot:"); console.Output.WriteLine("To get the token for your bot:");
console.Output.WriteLine(
" The token is generated during bot creation. If you lost it, generate a new one:"
);
console.Output.WriteLine(" 1. Go to Discord developer portal"); console.Output.WriteLine(" 1. Go to Discord developer portal");
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Reset Token");
console.Output.WriteLine(" 5. Click Yes, do it! and authenticate to confirm");
console.Output.WriteLine(
" * Integrations using the previous token will stop working until updated"
);
console.Output.WriteLine( console.Output.WriteLine(
" * Your bot needs to have the Message Content Intent enabled to read messages" " * Your bot needs to have the Message Content Intent enabled to read messages"
); );
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
{ {
None, None,
Active, Active,
All All,
} }
@@ -1,22 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed> <PublishTrimmed>true</PublishTrimmed>
<NoWarn>$(NoWarn);IL2104</NoWarn> <CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup> </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> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" /> <PackageReference Include="CliFx" Version="2.3.6" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.49.1" /> <PackageReference Include="Spectre.Console" Version="0.54.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" /> <ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project>
</Project>
+4
View File
@@ -3,6 +3,8 @@ using System.Threading.Tasks;
using CliFx; using CliFx;
using DiscordChatExporter.Cli.Commands; using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters; using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
namespace DiscordChatExporter.Cli; namespace DiscordChatExporter.Cli;
@@ -22,6 +24,8 @@ public static class Program
typeof(ThreadInclusionModeBindingConverter) typeof(ThreadInclusionModeBindingConverter)
)] )]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))] [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
public static async Task<int> Main(string[] args) => public static async Task<int> Main(string[] args) =>
await new CliApplicationBuilder() await new CliApplicationBuilder()
.AddCommand<ExportAllCommand>() .AddCommand<ExportAllCommand>()
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
internal static class ConsoleExtensions internal static class ConsoleExtensions
{ {
public static IAnsiConsole CreateAnsiConsole(this IConsole console) => extension(IConsole console)
AnsiConsole.Create( {
new AnsiConsoleSettings public IAnsiConsole CreateAnsiConsole() =>
{ AnsiConsole.Create(
Ansi = AnsiSupport.Detect, new AnsiConsoleSettings
ColorSystem = ColorSystemSupport.Detect, {
Out = new AnsiConsoleOutput(console.Output) Ansi = AnsiSupport.Detect,
} ColorSystem = ColorSystemSupport.Detect,
); Out = new AnsiConsoleOutput(console.Output),
}
public static Status CreateStatusTicker(this IConsole console) =>
console.CreateAnsiConsole().Status().AutoRefresh(true);
public static Progress CreateProgressTicker(this IConsole console) =>
console
.CreateAnsiConsole()
.Progress()
.AutoClear(false)
.AutoRefresh(true)
.HideCompleted(false)
.Columns(
new TaskDescriptionColumn { Alignment = Justify.Left },
new ProgressBarColumn(),
new PercentageColumn()
); );
public Status CreateStatusTicker() =>
console.CreateAnsiConsole().Status().AutoRefresh(true);
public Progress CreateProgressTicker() =>
console
.CreateAnsiConsole()
.Progress()
.AutoClear(false)
.AutoRefresh(true)
.HideCompleted(false)
.Columns(
new TaskDescriptionColumn { Alignment = Justify.Left },
new ProgressBarColumn(),
new PercentageColumn()
);
}
public static async ValueTask StartTaskAsync( public static async ValueTask StartTaskAsync(
this ProgressContext context, this ProgressContext context,
string description, string description,
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/application#application-object // https://discord.com/developers/docs/resources/application#application-object
public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags) public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags)
{ {
public bool IsMessageContentIntentEnabled => public bool IsMessageContentIntentEnabled { get; } =
Flags.HasFlag(ApplicationFlags.GatewayMessageContent) Flags.HasFlag(ApplicationFlags.GatewayMessageContent)
|| Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited); || Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
} }
@@ -16,5 +16,5 @@ public enum ApplicationFlags
Embedded = 131072, Embedded = 131072,
GatewayMessageContent = 262144, GatewayMessageContent = 262144,
GatewayMessageContentLimited = 524288, GatewayMessageContentLimited = 524288,
ApplicationCommandBadge = 8388608 ApplicationCommandBadge = 8388608,
} }
@@ -18,7 +18,7 @@ public partial record Attachment(
FileSize FileSize FileSize FileSize
) : IHasId ) : IHasId
{ {
public string FileExtension => Path.GetExtension(FileName); public string FileExtension { get; } = Path.GetExtension(FileName);
public bool IsImage => public bool IsImage =>
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase) string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
@@ -41,7 +41,7 @@ public partial record Attachment(
|| string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase) || string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase); || string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal); public bool IsSpoiler { get; } = FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
} }
public partial record Attachment public partial record Attachment
@@ -21,21 +21,23 @@ public partial record Channel(
Snowflake? LastMessageId Snowflake? LastMessageId
) : IHasId ) : IHasId
{ {
public bool IsDirect => Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat; public bool IsDirect { get; } =
Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
public bool IsGuild => !IsDirect; public bool IsGuild => !IsDirect;
public bool IsCategory => Kind == ChannelKind.GuildCategory; public bool IsCategory { get; } = Kind == ChannelKind.GuildCategory;
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice; public bool IsVoice { get; } =
Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
public bool IsThread => public bool IsThread { get; } =
Kind Kind
is ChannelKind.GuildNewsThread is ChannelKind.GuildNewsThread
or ChannelKind.GuildPublicThread or ChannelKind.GuildPublicThread
or ChannelKind.GuildPrivateThread; or ChannelKind.GuildPrivateThread;
public bool IsEmpty => LastMessageId is null; public bool IsEmpty { get; } = LastMessageId is null;
public IEnumerable<Channel> GetParents() public IEnumerable<Channel> GetParents()
{ {
@@ -67,7 +69,8 @@ public partial record Channel
var guildId = var guildId =
json.GetPropertyOrNull("guild_id") json.GetPropertyOrNull("guild_id")
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id; ?.Pipe(Snowflake.Parse)
?? Guild.DirectMessages.Id;
var name = var name =
// Guild channel // Guild channel
@@ -76,6 +79,7 @@ public partial record Channel
?? json.GetPropertyOrNull("recipients") ?? json.GetPropertyOrNull("recipients")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(User.Parse) ?.Select(User.Parse)
.OrderBy(u => u.Id)
.Select(u => u.DisplayName) .Select(u => u.DisplayName)
.Pipe(s => string.Join(", ", s)) .Pipe(s => string.Join(", ", s))
// Fallback // Fallback
@@ -93,7 +97,8 @@ public partial record Channel
var isArchived = var isArchived =
json.GetPropertyOrNull("thread_metadata") json.GetPropertyOrNull("thread_metadata")
?.GetPropertyOrNull("archived") ?.GetPropertyOrNull("archived")
?.GetBooleanOrNull() ?? false; ?.GetBooleanOrNull()
?? false;
var lastMessageId = json.GetPropertyOrNull("last_message_id") var lastMessageId = json.GetPropertyOrNull("last_message_id")
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelConnection(Channel Channel, IReadOnlyList<ChannelConnection> Children)
{
public static IReadOnlyList<ChannelConnection> BuildTree(IReadOnlyList<Channel> channels)
{
IReadOnlyList<ChannelConnection> GetChildren(Channel parent) =>
channels
.Where(c => c.Parent?.Id == parent.Id)
.Select(c => new ChannelConnection(c, GetChildren(c)))
.ToArray();
return channels
.Where(c => c.Parent is null)
.Select(c => new ChannelConnection(c, GetChildren(c)))
.ToArray();
}
}
@@ -14,5 +14,5 @@ public enum ChannelKind
GuildPrivateThread = 12, GuildPrivateThread = 12,
GuildStageVoice = 13, GuildStageVoice = 13,
GuildDirectory = 14, GuildDirectory = 14,
GuildForum = 15 GuildForum = 15,
} }
@@ -1,21 +0,0 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelNode(Channel Channel, IReadOnlyList<ChannelNode> Children)
{
public static IReadOnlyList<ChannelNode> BuildTree(IReadOnlyList<Channel> channels)
{
IReadOnlyList<ChannelNode> GetChildren(Channel parent) =>
channels
.Where(c => c.Parent?.Id == parent.Id)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
return channels
.Where(c => c.Parent is null)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
}
}
@@ -45,7 +45,11 @@ public partial record Embed
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind = var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() json.GetPropertyOrNull("type")
?.GetStringOrNull()
?.Pipe(s =>
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
)
?? EmbedKind.Rich; ?? EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
@@ -63,7 +67,8 @@ public partial record Embed
json.GetPropertyOrNull("fields") json.GetPropertyOrNull("fields")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(EmbedField.Parse) ?.Select(EmbedField.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
@@ -78,7 +83,8 @@ public partial record Embed
json.GetPropertyOrNull("image") json.GetPropertyOrNull("image")
?.Pipe(EmbedImage.Parse) ?.Pipe(EmbedImage.Parse)
.ToSingletonEnumerable() .ToSingletonEnumerable()
.ToArray() ?? []; .ToArray()
?? [];
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse); var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
@@ -7,5 +7,5 @@ public enum EmbedKind
Image, Image,
Video, Video,
Gifv, Gifv,
Link Link,
} }
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record SpotifyTrackEmbedProjection(string TrackId) public partial record SpotifyTrackEmbedProjection(string TrackId)
{ {
public string Url => $"https://open.spotify.com/embed/track/{TrackId}"; public string Url { get; } = $"https://open.spotify.com/embed/track/{TrackId}";
} }
public partial record SpotifyTrackEmbedProjection public partial record SpotifyTrackEmbedProjection
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record TwitchClipEmbedProjection(string ClipId) public partial record TwitchClipEmbedProjection(string ClipId)
{ {
public string Url => $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost"; public string Url { get; } = $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
} }
public partial record TwitchClipEmbedProjection public partial record TwitchClipEmbedProjection
@@ -2,7 +2,10 @@
public partial record YouTubeVideoEmbedProjection(string VideoId) public partial record YouTubeVideoEmbedProjection(string VideoId)
{ {
public string Url => $"https://www.youtube.com/embed/{VideoId}"; public string Url { get; } = $"https://www.youtube.com/watch?v={VideoId}";
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
} }
public partial record YouTubeVideoEmbedProjection public partial record YouTubeVideoEmbedProjection
+11 -20
View File
@@ -1,5 +1,4 @@
using System; using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -13,29 +12,22 @@ public partial record Emoji(
Snowflake? Id, 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, string Name,
bool IsAnimated, bool IsAnimated
string ImageUrl
) )
{ {
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 => Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name; public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public string ImageUrl { get; } =
Id is not null
? ImageCdn.GetCustomEmojiUrl(Id.Value, IsAnimated)
: ImageCdn.GetStandardEmojiUrl(Name);
} }
public partial record Emoji public partial record Emoji
{ {
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{
// Custom emoji
if (id is not null)
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
// Standard emoji
if (!string.IsNullOrWhiteSpace(name))
return ImageCdn.GetStandardEmojiUrl(name);
throw new InvalidOperationException("Either the emoji ID or name should be provided.");
}
public static Emoji Parse(JsonElement json) public static Emoji Parse(JsonElement json)
{ {
var id = json.GetPropertyOrNull("id") var id = json.GetPropertyOrNull("id")
@@ -47,8 +39,7 @@ public partial record Emoji
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji"; json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false; var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
var imageUrl = GetImageUrl(id, name, isAnimated);
return new Emoji(id, name, isAnimated, imageUrl); return new Emoji(id, name, isAnimated);
} }
} }
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/guild#guild-object // https://discord.com/developers/docs/resources/guild#guild-object
public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
{ {
public bool IsDirect => Id == DirectMessages.Id; public bool IsDirect { get; } = Id == Snowflake.Zero;
} }
public partial record Guild public partial record Guild
@@ -25,7 +25,8 @@ public partial record Guild
var iconUrl = var iconUrl =
json.GetPropertyOrNull("icon") json.GetPropertyOrNull("icon")
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl(); ?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
?? ImageCdn.GetFallbackUserAvatarUrl();
return new Guild(id, name, iconUrl); return new Guild(id, name, iconUrl);
} }
@@ -15,7 +15,7 @@ public partial record Member(
IReadOnlyList<Snowflake> RoleIds IReadOnlyList<Snowflake> RoleIds
) : IHasId ) : IHasId
{ {
public Snowflake Id => User.Id; public Snowflake Id { get; } = User.Id;
} }
public partial record Member public partial record Member
@@ -32,7 +32,8 @@ public partial record Member
?.EnumerateArray() ?.EnumerateArray()
.Select(j => j.GetNonWhiteSpaceString()) .Select(j => j.GetNonWhiteSpaceString())
.Select(Snowflake.Parse) .Select(Snowflake.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var avatarUrl = guildId is not null var avatarUrl = guildId is not null
? json.GetPropertyOrNull("avatar") ? json.GetPropertyOrNull("avatar")
@@ -30,15 +30,15 @@ public partial record Message(
Interaction? Interaction Interaction? Interaction
) : IHasId ) : IHasId
{ {
public bool IsSystemNotification => public bool IsSystemNotification { get; } =
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated; Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
public bool IsReply => Kind == MessageKind.Reply; public bool IsReply { get; } = Kind == MessageKind.Reply;
// App interactions are rendered as replies in the Discord client, but they are not actually replies // App interactions are rendered as replies in the Discord client, but they are not actually replies
public bool IsReplyLike => IsReply || Interaction is not null; public bool IsReplyLike => IsReply || Interaction is not null;
public bool IsEmpty => public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content) string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any() && !Attachments.Any()
&& !Embeds.Any() && !Embeds.Any()
@@ -143,7 +143,8 @@ public partial record Message
json.GetPropertyOrNull("attachments") json.GetPropertyOrNull("attachments")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Attachment.Parse) ?.Select(Attachment.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var embeds = NormalizeEmbeds( var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
@@ -154,13 +155,15 @@ public partial record Message
json.GetPropertyOrNull("sticker_items") json.GetPropertyOrNull("sticker_items")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Sticker.Parse) ?.Select(Sticker.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var reactions = var reactions =
json.GetPropertyOrNull("reactions") json.GetPropertyOrNull("reactions")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Reaction.Parse) ?.Select(Reaction.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var mentionedUsers = var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
@@ -14,5 +14,5 @@ public enum MessageFlags
Urgent = 16, Urgent = 16,
HasThread = 32, HasThread = 32,
Ephemeral = 64, Ephemeral = 64,
Loading = 128 Loading = 128,
} }
@@ -12,5 +12,5 @@ public enum MessageKind
ChannelPinnedMessage = 6, ChannelPinnedMessage = 6,
GuildMemberJoin = 7, GuildMemberJoin = 7,
ThreadCreated = 18, ThreadCreated = 18,
Reply = 19 Reply = 19,
} }
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/sticker#sticker-resource // https://discord.com/developers/docs/resources/sticker#sticker-resource
public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl) public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
{ {
public bool IsImage => Format != StickerFormat.Lottie; public bool IsImage { get; } = Format != StickerFormat.Lottie;
} }
public partial record Sticker public partial record Sticker
@@ -28,7 +28,7 @@ public partial record Sticker
StickerFormat.Apng => "png", StickerFormat.Apng => "png",
StickerFormat.Lottie => "json", StickerFormat.Lottie => "json",
StickerFormat.Gif => "gif", StickerFormat.Gif => "gif",
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.") _ => throw new InvalidOperationException($"Unknown sticker format '{format}'."),
} }
); );
@@ -5,5 +5,5 @@ public enum StickerFormat
Png = 1, Png = 1,
Apng = 2, Apng = 2,
Lottie = 3, Lottie = 3,
Gif = 4 Gif = 4,
} }
@@ -18,7 +18,7 @@ public partial record User(
string AvatarUrl string AvatarUrl
) : IHasId ) : IHasId
{ {
public string DiscriminatorFormatted => public string DiscriminatorFormatted { get; } =
Discriminator is not null ? $"{Discriminator:0000}" : "0000"; Discriminator is not null ? $"{Discriminator:0000}" : "0000";
// This effectively represents the user's true identity. // This effectively represents the user's true identity.
+228 -231
View File
@@ -18,7 +18,10 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Core.Discord;
public class DiscordClient(string token) public class DiscordClient(
string token,
RateLimitPreference rateLimitPreference = RateLimitPreference.RespectAll
)
{ {
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute); private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
private TokenKind? _resolvedTokenKind; private TokenKind? _resolvedTokenKind;
@@ -47,33 +50,41 @@ public class DiscordClient(string token)
innerCancellationToken innerCancellationToken
); );
// If this was the last request available before hitting the rate limit, // Discord has advisory rate limits (communicated via response headers), but they are typically
// wait out the reset time so that future requests can succeed. // way stricter than the actual rate limits enforced by the server.
// This may add an unnecessary delay in case the user doesn't intend to // The user may choose to ignore the advisory rate limits and only retry on hard rate limits,
// make any more requests, but implementing a smarter solution would // if they want to prioritize speed over compliance (and safety of their account/bot).
// require properly keeping track of Discord's global/per-route/per-resource // https://github.com/Tyrrrz/DiscordChatExporter/issues/1021
// rate limits and that's just way too much effort. if (rateLimitPreference.IsRespectedFor(tokenKind))
// https://discord.com/developers/docs/topics/rate-limits
var remainingRequestCount = response
.Headers.TryGetValue("X-RateLimit-Remaining")
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response
.Headers.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds);
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{ {
var delay = var remainingRequestCount = response
// Adding a small buffer to the reset time reduces the chance of getting .Headers.TryGetValue("X-RateLimit-Remaining")
// rate limited again, because it allows for more requests to be released. ?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken); var resetAfterDelay = response
.Headers.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.Parse(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.
// This may add an unnecessary delay in case the user doesn't intend to
// make any more requests, but implementing a smarter solution would
// require properly keeping track of Discord's global/per-route/per-resource
// rate limits and that's just way too much effort.
// https://discord.com/developers/docs/topics/rate-limits
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{
var delay =
// Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken);
}
} }
return response; return response;
@@ -133,35 +144,31 @@ public class DiscordClient(string token)
{ {
throw response.StatusCode switch throw response.StatusCode switch
{ {
HttpStatusCode.Unauthorized HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( "Authentication token is invalid.",
"Authentication token is invalid.", true
true ),
),
HttpStatusCode.Forbidden HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( $"Request to '{url}' failed: forbidden."
$"Request to '{url}' failed: forbidden." ),
),
HttpStatusCode.NotFound HttpStatusCode.NotFound => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( $"Request to '{url}' failed: not found."
$"Request to '{url}' failed: not found." ),
),
_ _ => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( $"""
$""" Request to '{url}' failed: {response
Request to '{url}' failed: {response .StatusCode.ToString()
.StatusCode.ToString() .ToSpaceSeparatedWords()
.ToSpaceSeparatedWords() .ToLowerInvariant()}.
.ToLowerInvariant()}. Response content: {await response.Content.ReadAsStringAsync(
Response content: {await response.Content.ReadAsStringAsync( cancellationToken
cancellationToken )}
)} """,
""", true
true ),
)
}; };
} }
@@ -298,184 +305,19 @@ public class DiscordClient(string token)
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
yield break; yield break;
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken)) var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
// Categories cannot have threads
.Where(c => !c.IsCategory)
// Voice channels cannot have threads
.Where(c => !c.IsVoice)
// Empty channels cannot have threads
.Where(c => !c.IsEmpty)
// If the 'before' boundary is specified, skip channels that don't have messages
// for that range, because thread-start event should always be accompanied by a message.
// Note that we don't perform a similar check for the 'after' boundary, because
// threads may have messages in range, even if the parent channel doesn't.
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
.ToArray();
// User accounts can only fetch threads using the search endpoint foreach (
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User) var channel in await GetChannelThreadsAsync(
channels,
includeArchived,
before,
after,
cancellationToken
)
)
{ {
// Active threads yield return channel;
foreach (var channel in channels)
{
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", "false")
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access or channels without threads
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
var breakOuter = false;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message time.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
{
breakOuter = true;
break;
}
yield return thread;
currentOffset++;
}
if (breakOuter)
break;
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
// Archived threads
if (includeArchived)
{
foreach (var channel in channels)
{
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", "true")
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access or channels without threads
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
var breakOuter = false;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message time.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
{
breakOuter = true;
break;
}
yield return thread;
currentOffset++;
}
if (breakOuter)
break;
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
// Bot accounts can only fetch threads using the threads endpoint
else
{
// Active threads
{
var parentsById = channels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync(
$"guilds/{guildId}/threads/active",
cancellationToken
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
{
var parent = threadJson
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent);
}
}
// Archived threads
if (includeArchived)
{
foreach (var channel in channels)
{
// Public archived threads
{
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/public",
cancellationToken
);
if (response is null)
continue;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
yield return Channel.Parse(threadJson, channel);
}
// Private archived threads
{
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/private",
cancellationToken
);
if (response is null)
continue;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
yield return Channel.Parse(threadJson, channel);
}
}
}
} }
} }
@@ -546,6 +388,163 @@ public class DiscordClient(string token)
} }
} }
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
IReadOnlyList<Channel> channels,
bool includeArchived = false,
Snowflake? before = null,
Snowflake? after = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
var filteredChannels = channels
// Categories cannot have threads
.Where(c => !c.IsCategory)
// Voice channels cannot have threads
.Where(c => !c.IsVoice)
// Empty channels cannot have threads
.Where(c => !c.IsEmpty)
// If the 'before' boundary is specified, skip channels that don't have messages
// for that range, because thread-start event should always be accompanied by a message.
// Note that we don't perform a similar check for the 'after' boundary, because
// threads may have messages in range, even if the parent channel doesn't.
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
.ToArray();
// User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
{
foreach (var channel in filteredChannels)
{
// Either include both active and archived threads, or only active threads
foreach (
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
)
{
// Offset is just the index of the last thread in the previous batch
var currentOffset = 0;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
// Can be null on channels that the user cannot access or channels without threads
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
var breakOuter = false;
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message timestamp.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
{
breakOuter = true;
break;
}
yield return thread;
currentOffset++;
}
if (breakOuter)
break;
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
// Bot accounts can only fetch threads using the threads endpoint
else
{
var guilds = new HashSet<Snowflake>();
foreach (var channel in filteredChannels)
guilds.Add(channel.GuildId);
// Active threads
foreach (var guildId in guilds)
{
var parentsById = filteredChannels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync(
$"guilds/{guildId}/threads/active",
cancellationToken
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
{
var parent = threadJson
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
if (filteredChannels.Contains(parent))
yield return Channel.Parse(threadJson, parent);
}
}
// Archived threads
if (includeArchived)
{
foreach (var channel in filteredChannels)
{
foreach (var archiveType in new[] { "public", "private" })
{
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
var currentBefore = before
?.ToDate()
.ToString("O", CultureInfo.InvariantCulture);
while (true)
{
// Threads are sorted by archive timestamp, not by last message timestamp
var url = new UrlBuilder()
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
.SetQueryParameter("before", currentBefore)
.Build();
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
foreach (
var threadJson in response
.Value.GetProperty("threads")
.EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
yield return thread;
currentBefore = threadJson
.GetProperty("thread_metadata")
.GetProperty("archive_timestamp")
.GetString();
}
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
}
}
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId, Snowflake channelId,
Snowflake? before = null, Snowflake? before = null,
@@ -692,9 +691,7 @@ public class DiscordClient(string token)
count++; count++;
} }
// Each batch can contain up to 100 users. if (count <= 0)
// If we got fewer, then it's definitely the last batch.
if (count < 100)
yield break; yield break;
} }
} }
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO.Compression; using System.IO.Compression;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -40,17 +41,19 @@ public partial class DataDump
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
using var archive = ZipFile.OpenRead(zipFilePath); await using var archive = await ZipFile.OpenReadAsync(zipFilePath, cancellationToken);
var entry = archive.GetEntry("messages/index.json"); // Use case-insensitive search to accommodate for different data dump versions
if (entry is null) // https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
{ var entry =
throw new InvalidOperationException( archive.Entries.FirstOrDefault(e =>
e.FullName.Equals("messages/index.json", StringComparison.OrdinalIgnoreCase)
)
?? throw new InvalidOperationException(
"Failed to locate the channel index inside the data package." "Failed to locate the channel index inside the data package."
); );
}
await using var stream = entry.Open(); await using var stream = await entry.OpenAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken); using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
return Parse(document.RootElement); return Parse(document.RootElement);
@@ -0,0 +1,38 @@
using System;
namespace DiscordChatExporter.Core.Discord;
[Flags]
public enum RateLimitPreference
{
IgnoreAll = 0,
RespectForUserTokens = 0b1,
RespectForBotTokens = 0b10,
RespectAll = RespectForUserTokens | RespectForBotTokens,
}
public static class RateLimitPreferenceExtensions
{
extension(RateLimitPreference rateLimitPreference)
{
internal bool IsRespectedFor(TokenKind tokenKind) =>
tokenKind switch
{
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens)
!= 0,
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens)
!= 0,
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
};
public string GetDisplayName() =>
rateLimitPreference switch
{
RateLimitPreference.IgnoreAll => "Always ignore",
RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
RateLimitPreference.RespectAll => "Always respect",
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)),
};
}
}
@@ -3,5 +3,5 @@
public enum TokenKind public enum TokenKind
{ {
User, User,
Bot Bot,
} }
@@ -1,15 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.4.2" /> <PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" /> <PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.4.0" /> <PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="RazorBlade" Version="0.6.0" /> <PackageReference Include="RazorBlade" Version="0.11.0" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.1.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageReference Include="YoutubeExplode" Version="6.3.16" /> <PackageReference Include="YoutubeExplode" Version="6.5.6" />
</ItemGroup> </ItemGroup>
</Project>
</Project>
@@ -0,0 +1,3 @@
namespace DiscordChatExporter.Core.Exceptions;
public class ChannelEmptyException(string message) : DiscordChatExporterException(message);
@@ -27,45 +27,43 @@ public class ChannelExporter(DiscordClient discord)
); );
} }
// Check if the channel is empty
if (request.Channel.IsEmpty)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages."
);
}
// Check if the 'after' boundary is valid
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period."
);
}
// Check if the 'before' boundary is valid
if (
request.Before is not null
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period."
);
}
// Build context // Build context
var context = new ExportContext(discord, request); var context = new ExportContext(discord, request);
await context.PopulateChannelsAndRolesAsync(cancellationToken); await context.PopulateChannelsAndRolesAsync(cancellationToken);
// Export messages // Initialize the exporter before further checks to ensure the file is created even if
// an exception is thrown after this point.
await using var messageExporter = new MessageExporter(context); await using var messageExporter = new MessageExporter(context);
// Check if the channel is empty
if (request.Channel.IsEmpty)
{
throw new ChannelEmptyException(
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages; an empty file will be created."
);
}
// Check if the 'before' and 'after' boundaries are valid
if (
(
request.Before is not null
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
)
|| (
request.After is not null
&& !request.Channel.MayHaveMessagesAfter(request.After.Value)
)
)
{
throw new ChannelEmptyException(
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period; an empty file will be created."
);
}
await foreach ( await foreach (
var message in discord.GetMessagesAsync( var message in discord.GetMessagesAsync(
request.Channel.Id, request.Channel.Id,
@@ -98,15 +96,5 @@ public class ChannelExporter(DiscordClient discord)
); );
} }
} }
// Throw if no messages were exported
if (messageExporter.MessagesExported <= 0)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
+ $"does not contain any matching messages within the specified period."
);
}
} }
} }
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
@@ -16,12 +15,7 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{ {
private static readonly AsyncKeyedLocker<string> Locker = private static readonly AsyncKeyedLocker<string> Locker = new();
new(o =>
{
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
// File paths of the previously downloaded assets // File paths of the previously downloaded assets
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
@@ -50,38 +44,8 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{ {
// Download the file // Download the file
using var response = await Http.Client.GetAsync(url, innerCancellationToken); using var response = await Http.Client.GetAsync(url, innerCancellationToken);
await using (var output = File.Create(filePath)) await using var output = File.Create(filePath);
await response.Content.CopyToAsync(output, innerCancellationToken); await response.Content.CopyToAsync(output, innerCancellationToken);
// Try to set the file date according to the last-modified header
try
{
var lastModified = response
.Content.Headers.TryGetValue("Last-Modified")
?.Pipe(s =>
DateTimeOffset.TryParse(
s,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var instant
)
? instant
: (DateTimeOffset?)null
);
if (lastModified is not null)
{
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
}
}
catch
{
// This can apparently fail for some reason.
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
}
}, },
cancellationToken cancellationToken
); );
@@ -111,7 +75,7 @@ internal partial class ExportAssetDownloader
return SHA256 return SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url))) .HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.ToHex() .Pipe(Convert.ToHexStringLower)
// 5 chars ought to be enough for anybody // 5 chars ought to be enough for anybody
.Truncate(5); .Truncate(5);
} }
@@ -139,7 +103,7 @@ internal partial class ExportAssetDownloader
fileExtension = ""; fileExtension = "";
} }
return PathEx.EscapeFileName( return Path.EscapeFileName(
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
); );
} }
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
@@ -18,8 +19,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
private readonly Dictionary<Snowflake, Channel> _channelsById = new(); private readonly Dictionary<Snowflake, Channel> _channelsById = new();
private readonly Dictionary<Snowflake, Role> _rolesById = new(); private readonly Dictionary<Snowflake, Role> _rolesById = new();
private readonly ExportAssetDownloader _assetDownloader = private readonly ExportAssetDownloader _assetDownloader = new(
new(request.AssetsDirPath, request.ShouldReuseAssets); request.AssetsDirPath,
request.ShouldReuseAssets
);
public DiscordClient Discord { get; } = discord; public DiscordClient Discord { get; } = discord;
@@ -96,7 +99,8 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
?.RoleIds.Select(TryGetRole) ?.RoleIds.Select(TryGetRole)
.WhereNotNull() .WhereNotNull()
.OrderByDescending(r => r.Position) .OrderByDescending(r => r.Position)
.ToArray() ?? []; .ToArray()
?? [];
public Color? TryGetUserColor(Snowflake id) => public Color? TryGetUserColor(Snowflake id) =>
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault(); GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
@@ -115,7 +119,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath); var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
// Prefer the relative path so that the export package can be copied around without breaking references. // Prefer the relative path so that the export package can be copied around without breaking references.
// However, if the assets directory lies outside of the export directory, use the absolute path instead. // However, if the assets directory lies outside the export directory, use the absolute path instead.
var shouldUseAbsoluteFilePath = var shouldUseAbsoluteFilePath =
relativeFilePath.StartsWith( relativeFilePath.StartsWith(
".." + Path.DirectorySeparatorChar, ".." + Path.DirectorySeparatorChar,
@@ -130,14 +134,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
// For HTML, the path needs to be properly formatted // For HTML, the path needs to be properly formatted
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight) if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
{ return Url.EncodeFilePath(optimalFilePath);
// Format the path into a valid file URI
var href = new Uri(new Uri("file:///"), optimalFilePath).ToString();
// File schema does not support relative paths, so strip it if that's the case
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1155
return shouldUseAbsoluteFilePath ? href : href[8..];
}
return optimalFilePath; return optimalFilePath;
} }
@@ -8,30 +8,33 @@ public enum ExportFormat
HtmlDark, HtmlDark,
HtmlLight, HtmlLight,
Csv, Csv,
Json Json,
} }
public static class ExportFormatExtensions public static class ExportFormatExtensions
{ {
public static string GetFileExtension(this ExportFormat format) => extension(ExportFormat format)
format switch {
{ public string GetFileExtension() =>
ExportFormat.PlainText => "txt", format switch
ExportFormat.HtmlDark => "html", {
ExportFormat.HtmlLight => "html", ExportFormat.PlainText => "txt",
ExportFormat.Csv => "csv", ExportFormat.HtmlDark => "html",
ExportFormat.Json => "json", ExportFormat.HtmlLight => "html",
_ => throw new ArgumentOutOfRangeException(nameof(format)) ExportFormat.Csv => "csv",
}; ExportFormat.Json => "json",
_ => throw new ArgumentOutOfRangeException(nameof(format)),
};
public static string GetDisplayName(this ExportFormat format) => public string GetDisplayName() =>
format switch format switch
{ {
ExportFormat.PlainText => "TXT", ExportFormat.PlainText => "TXT",
ExportFormat.HtmlDark => "HTML (Dark)", ExportFormat.HtmlDark => "HTML (Dark)",
ExportFormat.HtmlLight => "HTML (Light)", ExportFormat.HtmlLight => "HTML (Light)",
ExportFormat.Csv => "CSV", ExportFormat.Csv => "CSV",
ExportFormat.Json => "JSON", ExportFormat.Json => "JSON",
_ => throw new ArgumentOutOfRangeException(nameof(format)) _ => throw new ArgumentOutOfRangeException(nameof(format)),
}; };
}
} }
@@ -7,7 +7,6 @@ using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
@@ -145,7 +144,7 @@ public partial class ExportRequest
// File extension // File extension
buffer.Append('.').Append(format.GetFileExtension()); buffer.Append('.').Append(format.GetFileExtension());
return PathEx.EscapeFileName(buffer.ToString()); return Path.EscapeFileName(buffer.ToString());
} }
private static string FormatPath( private static string FormatPath(
@@ -159,7 +158,7 @@ public partial class ExportRequest
path, path,
"%.", "%.",
m => m =>
PathEx.EscapeFileName( Path.EscapeFileName(
m.Value switch m.Value switch
{ {
"%g" => guild.Id.ToString(), "%g" => guild.Id.ToString(),
@@ -172,24 +171,22 @@ public partial class ExportRequest
"%C" => channel.Name, "%C" => channel.Name,
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0", "%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
"%P" "%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
=> channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
?? "0",
"%a" "%a" => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
=> after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
?? "", "%b" => before
"%b" ?.ToDate()
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) .ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "", ?? "",
"%d" "%d" => DateTimeOffset.Now.ToString(
=> DateTimeOffset.Now.ToString( "yyyy-MM-dd",
"yyyy-MM-dd", CultureInfo.InvariantCulture
CultureInfo.InvariantCulture ),
),
"%%" => "%", "%%" => "%",
_ => m.Value _ => m.Value,
} }
) )
); );
@@ -3,5 +3,5 @@
internal enum BinaryExpressionKind internal enum BinaryExpressionKind
{ {
Or, Or,
And And,
} }
@@ -14,6 +14,6 @@ internal class BinaryExpressionMessageFilter(
{ {
BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message), BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message), BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.") _ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'."),
}; };
} }
@@ -17,15 +17,13 @@ internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage), MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio), MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
MessageContentMatchKind.Pin => message.IsPinned, MessageContentMatchKind.Pin => message.IsPinned,
MessageContentMatchKind.Invite MessageContentMatchKind.Invite => MarkdownParser
=> MarkdownParser .ExtractLinks(message.Content)
.ExtractLinks(message.Content) .Select(l => l.Url)
.Select(l => l.Url) .Select(Invite.TryGetCodeFromUrl)
.Select(Invite.TryGetCodeFromUrl) .Any(c => !string.IsNullOrWhiteSpace(c)),
.Any(c => !string.IsNullOrWhiteSpace(c)), _ => throw new InvalidOperationException(
_ $"Unknown message content match kind '{kind}'."
=> throw new InvalidOperationException( ),
$"Unknown message content match kind '{kind}'."
)
}; };
} }
@@ -9,5 +9,5 @@ internal enum MessageContentMatchKind
Image, Image,
Sound, Sound,
Pin, Pin,
Invite Invite,
} }
@@ -125,7 +125,7 @@ internal static class FilterGrammar
op switch op switch
{ {
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or), '|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And) _ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And),
} }
); );
@@ -5,7 +5,6 @@ using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -34,58 +33,51 @@ internal partial class HtmlMarkdownVisitor(
{ {
var (openingTag, closingTag) = formatting.Kind switch var (openingTag, closingTag) = formatting.Kind switch
{ {
FormattingKind.Bold FormattingKind.Bold => (
=> ( // lang=html
// lang=html "<strong>",
"<strong>", // lang=html
// lang=html "</strong>"
"</strong>" ),
),
FormattingKind.Italic FormattingKind.Italic => (
=> ( // lang=html
// lang=html "<em>",
"<em>", // lang=html
// lang=html "</em>"
"</em>" ),
),
FormattingKind.Underline FormattingKind.Underline => (
=> ( // lang=html
// lang=html "<u>",
"<u>", // lang=html
// lang=html "</u>"
"</u>" ),
),
FormattingKind.Strikethrough FormattingKind.Strikethrough => (
=> ( // lang=html
// lang=html "<s>",
"<s>", // lang=html
// lang=html "</s>"
"</s>" ),
),
FormattingKind.Spoiler FormattingKind.Spoiler => (
=> ( // lang=html
// lang=html """<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""", // lang=html
// lang=html """</span>"""
"""</span>""" ),
),
FormattingKind.Quote FormattingKind.Quote => (
=> ( // lang=html
// lang=html """<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""", // lang=html
// lang=html """</div></div>"""
"""</div></div>""" ),
),
_ _ => throw new InvalidOperationException(
=> throw new InvalidOperationException( $"Unknown formatting kind '{formatting.Kind}'."
$"Unknown formatting kind '{formatting.Kind}'." ),
)
}; };
buffer.Append(openingTag); buffer.Append(openingTag);
@@ -217,7 +209,6 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = isJumbo ? "chatlog__emoji--large" : ""; var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
buffer.Append( buffer.Append(
@@ -228,7 +219,7 @@ internal partial class HtmlMarkdownVisitor(
class="chatlog__emoji {jumboClass}" class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}" alt="{emoji.Name}"
title="{emoji.Code}" title="{emoji.Code}"
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}"> src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
""" """
); );
} }
@@ -8,14 +8,20 @@ internal static class HtmlMessageExtensions
{ {
// Message content is hidden if it's a link to an embedded media // Message content is hidden if it's a link to an embedded media
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682 // https://github.com/Tyrrrz/DiscordChatExporter/issues/682
public static bool IsContentHidden(this Message message) extension(Message message)
{ {
if (message.Embeds.Count != 1) public bool IsContentHidden()
return false; {
if (message.Embeds.Count != 1)
return false;
var embed = message.Embeds[0]; var embed = message.Embeds[0];
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) return string.Equals(
&& embed.Kind is EmbedKind.Image or EmbedKind.Gifv; message.Content.Trim(),
embed.Url,
StringComparison.OrdinalIgnoreCase
) && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
}
} }
} }
@@ -15,7 +15,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
private readonly TextWriter _writer = new StreamWriter(stream); private readonly TextWriter _writer = new StreamWriter(stream);
private readonly HtmlMinifier _minifier = new(); private readonly HtmlMinifier _minifier = new();
private readonly List<Message> _messageGroup = new(); private readonly List<Message> _messageGroup = [];
private bool CanJoinGroup(Message message) private bool CanJoinGroup(Message message)
{ {
@@ -90,7 +90,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
await new MessageGroupTemplate await new MessageGroupTemplate
{ {
Context = Context, Context = Context,
Messages = messages Messages = messages,
}.RenderAsync(cancellationToken) }.RenderAsync(cancellationToken)
) )
); );
@@ -131,7 +131,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
await new PostambleTemplate await new PostambleTemplate
{ {
Context = Context, Context = Context,
MessagesWritten = MessagesWritten MessagesWritten = MessagesWritten,
}.RenderAsync(cancellationToken) }.RenderAsync(cancellationToken)
) )
); );
@@ -1,12 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Writing; using JsonExtensions.Writing;
@@ -15,19 +17,18 @@ namespace DiscordChatExporter.Core.Exporting;
internal class JsonMessageWriter(Stream stream, ExportContext context) internal class JsonMessageWriter(Stream stream, ExportContext context)
: MessageWriter(stream, context) : MessageWriter(stream, context)
{ {
private readonly Utf8JsonWriter _writer = private readonly Utf8JsonWriter _writer = new(
new( stream,
stream, new JsonWriterOptions
new JsonWriterOptions {
{ // https://github.com/Tyrrrz/DiscordChatExporter/issues/450
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450 Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Indented = true,
Indented = true, // Validation errors may mask actual failures
// Validation errors may mask actual failures // https://github.com/Tyrrrz/DiscordChatExporter/issues/413
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413 SkipValidation = true,
SkipValidation = true }
} );
);
private async ValueTask<string> FormatMarkdownAsync( private async ValueTask<string> FormatMarkdownAsync(
string markdown, string markdown,
@@ -37,22 +38,31 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken) ? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
: markdown; : markdown;
private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default) private async ValueTask WriteUserAsync(
User user,
bool includeRoles = true,
CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", user.Id.ToString()); _writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name); _writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted); _writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString( _writer.WriteString(
"nickname", "nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
); );
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex()); _writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot); _writer.WriteBoolean("isBot", user.IsBot);
_writer.WritePropertyName("roles"); if (includeRoles)
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken); {
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
}
_writer.WriteString( _writer.WriteString(
"avatarUrl", "avatarUrl",
@@ -66,6 +76,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
private async ValueTask WriteEmojiAsync(
Emoji emoji,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", emoji.Id.ToString());
_writer.WriteString("name", emoji.Name);
_writer.WriteString("code", emoji.Code);
_writer.WriteBoolean("isAnimated", emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteRolesAsync( private async ValueTask WriteRolesAsync(
IReadOnlyList<Role> roles, IReadOnlyList<Role> roles,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
@@ -108,6 +138,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
} }
_writer.WriteEndObject(); _writer.WriteEndObject();
@@ -130,6 +162,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("canonicalUrl", embedImage.Url);
} }
_writer.WriteNumber("width", embedImage.Width); _writer.WriteNumber("width", embedImage.Width);
@@ -155,6 +189,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("canonicalUrl", embedVideo.Url);
} }
_writer.WriteNumber("width", embedVideo.Width); _writer.WriteNumber("width", embedVideo.Width);
@@ -182,6 +218,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
} }
_writer.WriteEndObject(); _writer.WriteEndObject();
@@ -273,6 +311,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteEndArray(); _writer.WriteEndArray();
// Inline emoji
_writer.WriteStartArray("inlineEmojis");
if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (
var emoji in MarkdownParser
.ExtractEmojis(embed.Description)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}
}
_writer.WriteEndArray();
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
@@ -373,7 +431,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
// Author // Author
_writer.WritePropertyName("author"); _writer.WritePropertyName("author");
await WriteUserAsync(message.Author, cancellationToken); await WriteUserAsync(message.Author, true, cancellationToken);
// Attachments // Attachments
_writer.WriteStartArray("attachments"); _writer.WriteStartArray("attachments");
@@ -431,20 +489,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartObject(); _writer.WriteStartObject();
// Emoji // Emoji
_writer.WriteStartObject("emoji"); _writer.WritePropertyName("emoji");
_writer.WriteString("id", reaction.Emoji.Id.ToString()); await WriteEmojiAsync(reaction.Emoji, cancellationToken);
_writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteString("code", reaction.Emoji.Code);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject();
_writer.WriteNumber("count", reaction.Count); _writer.WriteNumber("count", reaction.Count);
// Reaction authors
_writer.WriteStartArray("users"); _writer.WriteStartArray("users");
await foreach ( await foreach (
var user in Context.Discord.GetMessageReactionsAsync( var user in Context.Discord.GetMessageReactionsAsync(
Context.Request.Channel.Id, Context.Request.Channel.Id,
@@ -454,28 +506,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
) )
) )
{ {
_writer.WriteStartObject(); await WriteUserAsync(user, false, cancellationToken);
// Write limited user information without color and roles,
// so we can avoid fetching guild member information for each user.
_writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteBoolean("isBot", user.IsBot);
_writer.WriteString(
"avatarUrl",
await Context.ResolveAssetUrlAsync(
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
cancellationToken
)
);
_writer.WriteEndObject();
} }
_writer.WriteEndArray(); _writer.WriteEndArray();
@@ -487,9 +518,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
// Mentions // Mentions
_writer.WriteStartArray("mentions"); _writer.WriteStartArray("mentions");
foreach (var user in message.MentionedUsers) foreach (var user in message.MentionedUsers)
await WriteUserAsync(user, cancellationToken); await WriteUserAsync(user, true, cancellationToken);
_writer.WriteEndArray(); _writer.WriteEndArray();
@@ -512,11 +542,28 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteString("name", message.Interaction.Name); _writer.WriteString("name", message.Interaction.Name);
_writer.WritePropertyName("user"); _writer.WritePropertyName("user");
await WriteUserAsync(message.Interaction.User, cancellationToken); await WriteUserAsync(message.Interaction.User, true, cancellationToken);
_writer.WriteEndObject(); _writer.WriteEndObject();
} }
// Inline emoji
_writer.WriteStartArray("inlineEmojis");
foreach (
var emoji in MarkdownParser
.ExtractEmojis(message.Content)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}
_writer.WriteEndArray();
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
@@ -13,24 +13,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
public long MessagesExported { get; private set; } public long MessagesExported { get; private set; }
private async ValueTask ResetWriterAsync(CancellationToken cancellationToken = default) private async ValueTask<MessageWriter> InitializeWriterAsync(
{
if (_writer is not null)
{
try
{
await _writer.WritePostambleAsync(cancellationToken);
}
// Writer must be disposed, even if it fails to write the postamble
finally
{
await _writer.DisposeAsync();
_writer = null;
}
}
}
private async ValueTask<MessageWriter> GetWriterAsync(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
@@ -43,7 +26,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
) )
) )
{ {
await ResetWriterAsync(cancellationToken); await UninitializeWriterAsync(cancellationToken);
_partitionIndex++; _partitionIndex++;
} }
@@ -60,17 +43,41 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
return _writer = writer; return _writer = writer;
} }
private async ValueTask UninitializeWriterAsync(CancellationToken cancellationToken = default)
{
if (_writer is not null)
{
try
{
await _writer.WritePostambleAsync(cancellationToken);
}
// Writer must be disposed, even if it fails to write the postamble
finally
{
await _writer.DisposeAsync();
_writer = null;
}
}
}
public async ValueTask ExportMessageAsync( public async ValueTask ExportMessageAsync(
Message message, Message message,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
var writer = await GetWriterAsync(cancellationToken); var writer = await InitializeWriterAsync(cancellationToken);
await writer.WriteMessageAsync(message, cancellationToken); await writer.WriteMessageAsync(message, cancellationToken);
MessagesExported++; MessagesExported++;
} }
public async ValueTask DisposeAsync() => await ResetWriterAsync(); public async ValueTask DisposeAsync()
{
// If not messages were written, force the creation of an empty file
if (MessagesExported <= 0)
_ = await InitializeWriterAsync();
await UninitializeWriterAsync();
}
} }
internal partial class MessageExporter internal partial class MessageExporter
@@ -100,13 +107,15 @@ internal partial class MessageExporter
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context), ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context), ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"), ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
ExportFormat.HtmlLight ExportFormat.HtmlLight => new HtmlMessageWriter(
=> new HtmlMessageWriter(File.Create(filePath), context, "Light"), File.Create(filePath),
context,
"Light"
),
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context), ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
_ _ => throw new ArgumentOutOfRangeException(
=> throw new ArgumentOutOfRangeException( nameof(format),
nameof(format), $"Unknown export format '{format}'."
$"Unknown export format '{format}'." ),
)
}; };
} }
@@ -388,7 +388,7 @@
<div class="chatlog__embed-author-container"> <div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{ {
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> <img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
} }
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
@@ -424,9 +424,11 @@
</div> </div>
} }
@* Video player *@ @* Video thumbnail *@
<div class="chatlog__embed-youtube-container"> <div class="chatlog__embed-youtube-container">
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe> <a href="@youTubeVideoEmbed.Url">
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -441,9 +443,12 @@
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
embed.Url; embed.Url;
var embedImageCanonicalUrl =
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
<div class="chatlog__embed"> <div class="chatlog__embed">
<a href="@await ResolveAssetUrlAsync(embedImageUrl)"> <a href="@await ResolveAssetUrlAsync(embedImageUrl)">
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy"> <img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
</a> </a>
</div> </div>
} }
@@ -457,8 +462,10 @@
embed.Video?.ProxyUrl ?? embed.Video?.Url ?? embed.Video?.ProxyUrl ?? embed.Video?.Url ??
embed.Url; embed.Url;
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
<div class="chatlog__embed"> <div class="chatlog__embed">
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls> <video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video"> <source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
</video> </video>
</div> </div>
@@ -470,8 +477,10 @@
embed.Video?.ProxyUrl ?? embed.Video?.Url ?? embed.Video?.ProxyUrl ?? embed.Video?.Url ??
embed.Url; embed.Url;
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
<div class="chatlog__embed"> <div class="chatlog__embed">
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()"> <video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv"> <source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
</video> </video>
</div> </div>
@@ -499,7 +508,7 @@
<div class="chatlog__embed-author-container"> <div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{ {
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> <img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
} }
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
@@ -574,7 +583,7 @@
{ {
<div class="chatlog__embed-thumbnail-container"> <div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)"> <a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy"> <img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
</a> </a>
</div> </div>
} }
@@ -590,7 +599,7 @@
{ {
<div class="chatlog__embed-image-container"> <div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)"> <a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy"> <img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
</a> </a>
</div> </div>
} }
@@ -605,7 +614,7 @@
@* Footer icon *@ @* Footer icon *@
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{ {
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy"> <img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
} }
<span class="chatlog__embed-footer-text"> <span class="chatlog__embed-footer-text">
@@ -37,7 +37,7 @@ public partial class PartitionLimit
"M" => 1_000_000, "M" => 1_000_000,
"K" => 1_000, "K" => 1_000,
"" => 1, "" => 1,
_ => -1 _ => -1,
}; };
if (magnitude < 0) if (magnitude < 0)
@@ -7,40 +7,40 @@ namespace DiscordChatExporter.Core.Exporting;
internal static class PlainTextMessageExtensions internal static class PlainTextMessageExtensions
{ {
public static string GetFallbackContent(this Message message) => extension(Message message)
message.Kind switch {
{ public string GetFallbackContent() =>
MessageKind.RecipientAdd message.Kind switch
=> message.MentionedUsers.Any() {
MessageKind.RecipientAdd => message.MentionedUsers.Any()
? $"Added {message.MentionedUsers.First().DisplayName} to the group." ? $"Added {message.MentionedUsers.First().DisplayName} to the group."
: "Added a recipient.", : "Added a recipient.",
MessageKind.RecipientRemove MessageKind.RecipientRemove => message.MentionedUsers.Any()
=> message.MentionedUsers.Any()
? message.Author.Id == message.MentionedUsers.First().Id ? message.Author.Id == message.MentionedUsers.First().Id
? "Left the group." ? "Left the group."
: $"Removed {message.MentionedUsers.First().DisplayName} from the group." : $"Removed {message.MentionedUsers.First().DisplayName} from the group."
: "Removed a recipient.", : "Removed a recipient.",
MessageKind.Call MessageKind.Call =>
=> $"Started a call that lasted { $"Started a call that lasted {
message message
.CallEndedTimestamp? .CallEndedTimestamp?
.Pipe(t => t - message.Timestamp) .Pipe(t => t - message.Timestamp)
.Pipe(t => t.TotalMinutes) .Pipe(t => t.TotalMinutes)
.ToString("n0", CultureInfo.InvariantCulture) ?? "0" .ToString("n0", CultureInfo.InvariantCulture) ?? "0"
} minutes.", } minutes.",
MessageKind.ChannelNameChange MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
=> !string.IsNullOrWhiteSpace(message.Content)
? $"Changed the channel name: {message.Content}" ? $"Changed the channel name: {message.Content}"
: "Changed the channel name.", : "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.", MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.", MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.", MessageKind.GuildMemberJoin => "Joined the server.",
_ => message.Content _ => message.Content,
}; };
}
} }
@@ -711,9 +711,11 @@
margin-top: 0.6rem; margin-top: 0.6rem;
} }
.chatlog__embed-youtube { .chatlog__embed-youtube-thumbnail {
border: 0; max-width: 400px;
max-height: 225px;
border-radius: 3px; border-radius: 3px;
cursor: pointer;
} }
.chatlog__sticker { .chatlog__sticker {
+11 -5
View File
@@ -1,5 +1,5 @@
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Markdown; namespace DiscordChatExporter.Core.Markdown;
@@ -11,11 +11,17 @@ internal record EmojiNode(
bool IsAnimated bool IsAnimated
) : MarkdownNode ) : MarkdownNode
{ {
public bool IsCustomEmoji => Id is not null; // This coupling is unsound from the domain-design perspective, but it helps us reuse
// some code for now. We can refactor this later, if the coupling becomes a problem.
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile) private readonly Emoji _emoji = new(Id, Name, IsAnimated);
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public EmojiNode(string name) public EmojiNode(string name)
: this(null, name, false) { } : this(null, name, false) { }
public bool IsCustomEmoji => _emoji.IsCustomEmoji;
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => _emoji.Code;
public string ImageUrl => _emoji.ImageUrl;
} }
@@ -7,5 +7,5 @@ internal enum FormattingKind
Underline, Underline,
Strikethrough, Strikethrough,
Spoiler, Spoiler,
Quote Quote,
} }
@@ -6,5 +6,5 @@ internal enum MentionKind
Here, Here,
User, User,
Channel, Channel,
Role Role,
} }
@@ -2,20 +2,19 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatcher<T> internal class AggregateMatcher<TContext, TValue>(
params IReadOnlyList<IMatcher<TContext, TValue>> matchers
) : IMatcher<TContext, TValue>
{ {
public AggregateMatcher(params IMatcher<T>[] matchers) public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
ParsedMatch<T>? earliestMatch = null; ParsedMatch<TValue>? earliestMatch = null;
// Try to match the input with each matcher and get the match with the lowest start index // Try to match the input with each matcher and get the match with the lowest start index
foreach (var matcher in matchers) foreach (var matcher in matchers)
{ {
// Try to match // Try to match
var match = matcher.TryMatch(segment); var match = matcher.TryMatch(context, segment);
// If there's no match - continue // If there's no match - continue
if (match is null) if (match is null)
@@ -26,7 +25,9 @@ internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatch
earliestMatch is null earliestMatch is null
|| match.Segment.StartIndex < earliestMatch.Segment.StartIndex || match.Segment.StartIndex < earliestMatch.Segment.StartIndex
) )
{
earliestMatch = match; earliestMatch = match;
}
// If the earliest match starts at the very beginning - break, // If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that // because it's impossible to find a match earlier than that
@@ -3,17 +3,18 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal interface IMatcher<T> internal interface IMatcher<in TContext, TValue>
{ {
ParsedMatch<T>? TryMatch(StringSegment segment); ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment);
} }
internal static class MatcherExtensions internal static class MatcherExtensions
{ {
public static IEnumerable<ParsedMatch<T>> MatchAll<T>( public static IEnumerable<ParsedMatch<TValue>> MatchAll<TContext, TValue>(
this IMatcher<T> matcher, this IMatcher<TContext, TValue> matcher,
TContext context,
StringSegment segment, StringSegment segment,
Func<StringSegment, T> transformFallback Func<TContext, StringSegment, TValue> transformFallback
) )
{ {
// Loop through segments divided by individual matches // Loop through segments divided by individual matches
@@ -22,6 +23,7 @@ internal static class MatcherExtensions
{ {
// Find a match within this segment // Find a match within this segment
var match = matcher.TryMatch( var match = matcher.TryMatch(
context,
segment.Relocate(currentIndex, segment.EndIndex - currentIndex) segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
); );
@@ -36,9 +38,9 @@ internal static class MatcherExtensions
match.Segment.StartIndex - currentIndex match.Segment.StartIndex - currentIndex
); );
yield return new ParsedMatch<T>( yield return new ParsedMatch<TValue>(
fallbackSegment, fallbackSegment,
transformFallback(fallbackSegment) transformFallback(context, fallbackSegment)
); );
} }
@@ -53,7 +55,10 @@ internal static class MatcherExtensions
{ {
var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex); var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment)); yield return new ParsedMatch<TValue>(
fallbackSegment,
transformFallback(context, fallbackSegment)
);
} }
} }
} }
@@ -0,0 +1,3 @@
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal readonly record struct MarkdownContext(int Depth = 0);
@@ -23,15 +23,15 @@ internal static partial class MarkdownParser
/* Formatting */ /* Formatting */
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> BoldFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly two closing asterisks. // There must be exactly two closing asterisks.
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1]))) (c, s, m) => new FormattingNode(FormattingKind.Bold, Parse(c, s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly one closing asterisk. // There must be exactly one closing asterisk.
// Opening asterisk must not be followed by whitespace. // Opening asterisk must not be followed by whitespace.
// Closing asterisk must not be preceded by whitespace. // Closing asterisk must not be preceded by whitespace.
@@ -39,156 +39,174 @@ internal static partial class MarkdownParser
@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)", @"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
DefaultRegexOptions | RegexOptions.Singleline DefaultRegexOptions | RegexOptions.Singleline
), ),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]))) (c, s, m) =>
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = private static readonly IMatcher<
new RegexMatcher<MarkdownNode>( MarkdownContext,
// There must be exactly three closing asterisks. MarkdownNode
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), > ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
(s, m) => // There must be exactly three closing asterisks.
new FormattingNode( new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
FormattingKind.Italic, (c, s, m) =>
Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher) new FormattingNode(
) FormattingKind.Italic,
); Parse(c, s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Closing underscore must not be followed by a word character.
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// There must be exactly two closing underscores.
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
)
);
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) =>
new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Include the linebreak in the content so that the lines are preserved in quotes.
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Include the linebreaks in the content, so that the lines are preserved in quotes.
// Empty content is allowed within quotes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
(s, m) =>
new FormattingNode(
FormattingKind.Quote,
m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
)
);
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(s, m) =>
new ListNode(
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
) )
); );
/* Code blocks */ private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicAltFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = // Closing underscore must not be followed by a word character.
new RegexMatcher<MarkdownNode>( new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
// One or two backticks are allowed, but they must match on both sides. (c, s, m) =>
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline), new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value)
); );
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> UnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly two closing underscores.
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Underline, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> ItalicUnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(c, s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
)
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Strikethrough, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> SpoilerFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Spoiler, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> SingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Include the linebreak in the content so that the lines are preserved in quotes.
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// Include the linebreaks in the content, so that the lines are preserved in quotes.
// Empty content is allowed within quotes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
(c, s, m) =>
new FormattingNode(
FormattingKind.Quote,
m.Groups[1].Captures.SelectMany(r => Parse(c, s.Relocate(r))).ToArray()
)
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineQuoteNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> HeadingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
(c, s, m) => new HeadingNode(m.Groups[1].Length, Parse(c, s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> ListNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(c, s, m) =>
new ListNode(
m.Groups[2]
.Captures.Select(x => new ListItemNode(Parse(c, s.Relocate(x))))
.ToArray()
)
);
/* Code blocks */
private static readonly IMatcher<MarkdownContext, MarkdownNode> InlineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// One or two backticks are allowed, but they must match on both sides.
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
(_, _, m) => new InlineCodeBlockNode(m.Groups[2].Value)
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak. // Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
// Blank lines at the beginning and at the end of content are trimmed. // Blank lines at the beginning and at the end of content are trimmed.
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
(_, m) => (_, _, m) =>
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')) new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
); );
/* Mentions */ /* Mentions */
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> EveryoneMentionNodeMatcher =
new StringMatcher<MarkdownNode>( new StringMatcher<MarkdownContext, MarkdownNode>(
"@everyone", "@everyone",
_ => new MentionNode(null, MentionKind.Everyone) (_, _) => new MentionNode(null, MentionKind.Everyone)
); );
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> HereMentionNodeMatcher =
new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here)); new StringMatcher<MarkdownContext, MarkdownNode>(
"@here",
(_, _) => new MentionNode(null, MentionKind.Here)
);
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> UserMentionNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <@123456> or <@!123456> // Capture <@123456> or <@!123456>
new Regex(@"<@!?(\d+)>", DefaultRegexOptions), new Regex(@"<@!?(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User) (_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
); );
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> ChannelMentionNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <#123456> // Capture <#123456>
new Regex(@"<\#!?(\d+)>", DefaultRegexOptions), new Regex(@"<\#!?(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel) (_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
); );
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> RoleMentionNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <@&123456> // Capture <@&123456>
new Regex(@"<@&(\d+)>", DefaultRegexOptions), new Regex(@"<@&(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role) (_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
); );
/* Emoji */ /* Emoji */
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex( new Regex(
@"(" @"("
+ +
@@ -239,21 +257,21 @@ internal static partial class MarkdownParser
+ @")", + @")",
DefaultRegexOptions DefaultRegexOptions
), ),
(_, m) => new EmojiNode(m.Groups[1].Value) (_, _, m) => new EmojiNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> CodedStandardEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture :thinking: // Capture :thinking:
new Regex(@":([\w_]+):", DefaultRegexOptions), new Regex(@":([\w_]+):", DefaultRegexOptions),
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n)) (_, _, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
); );
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> CustomEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <:lul:123456> or <a:lul:123456> // Capture <:lul:123456> or <a:lul:123456>
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions), new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
(_, m) => (_, _, m) =>
new EmojiNode( new EmojiNode(
Snowflake.TryParse(m.Groups[3].Value), Snowflake.TryParse(m.Groups[3].Value),
m.Groups[2].Value, m.Groups[2].Value,
@@ -263,70 +281,72 @@ internal static partial class MarkdownParser
/* Links */ /* Links */
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> AutoLinkNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Any non-whitespace character after http:// or https:// // Any non-whitespace character after http:// or https://
// until the last punctuation character or whitespace. // until the last punctuation character or whitespace.
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions), new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
(_, m) => new LinkNode(m.Groups[1].Value) (_, _, m) => new LinkNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> HiddenLinkNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Same as auto link but also surrounded by angular brackets // Same as auto link but also surrounded by angular brackets
new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions), new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions),
(_, m) => new LinkNode(m.Groups[1].Value) (_, _, m) => new LinkNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture [title](link) // Capture [title](link)
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions), new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1]))) (c, s, m) => new LinkNode(m.Groups[2].Value, Parse(c, s.Relocate(m.Groups[1])))
); );
/* Text */ /* Text */
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> ShrugTextNodeMatcher =
new StringMatcher<MarkdownNode>( new StringMatcher<MarkdownContext, MarkdownNode>(
// Capture the shrug kaomoji. // Capture the shrug kaomoji.
// This escapes it from matching for formatting. // This escapes it from matching for formatting.
@"¯\_(ツ)_/¯", @"¯\_(ツ)_/¯",
s => new TextNode(s.ToString()) (s, _) => new TextNode(s.ToString())
); );
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture some specific emoji that don't get rendered. // Capture some specific emoji that don't get rendered.
// This escapes them from matching for emoji. // This escapes them from matching for emoji.
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions), new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value) (_, _, m) => new TextNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> EscapedSymbolTextNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture any "symbol/other" character or surrogate pair preceded by a backslash. // Capture any "symbol/other" character or surrogate pair preceded by a backslash.
// This escapes them from matching for emoji. // This escapes them from matching for emoji.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230 // https://github.com/Tyrrrz/DiscordChatExporter/issues/230
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions), new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value) (_, _, m) => new TextNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = private static readonly IMatcher<
new RegexMatcher<MarkdownNode>( MarkdownContext,
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash. MarkdownNode
// This escapes them from matching for formatting or other tokens. > EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions), // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
(_, m) => new TextNode(m.Groups[1].Value) // This escapes them from matching for formatting or other tokens.
); new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
(_, _, m) => new TextNode(m.Groups[1].Value)
);
/* Misc */ /* Misc */
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> TimestampNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <t:12345678> or <t:12345678:R> // Capture <t:12345678> or <t:12345678:R>
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions), new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
(_, m) => (_, _, m) =>
{ {
try try
{ {
@@ -357,10 +377,9 @@ internal static partial class MarkdownParser
null => null, null => null,
// Unknown format: throw an exception to consider this timestamp invalid // Unknown format: throw an exception to consider this timestamp invalid
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1156 // https://github.com/Tyrrrz/DiscordChatExporter/issues/1156
var f var f => throw new InvalidOperationException(
=> throw new InvalidOperationException( $"Unknown timestamp format '{f}'."
$"Unknown timestamp format '{f}'." ),
)
}; };
return new TimestampNode(instant, format); return new TimestampNode(instant, format);
@@ -382,50 +401,51 @@ internal static partial class MarkdownParser
); );
// Matchers that have similar patterns are ordered from most specific to least specific // Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownContext, MarkdownNode> NodeMatcher =
// Escaped text new AggregateMatcher<MarkdownContext, MarkdownNode>(
ShrugTextNodeMatcher, // Escaped text
IgnoredEmojiTextNodeMatcher, ShrugTextNodeMatcher,
EscapedSymbolTextNodeMatcher, IgnoredEmojiTextNodeMatcher,
EscapedCharacterTextNodeMatcher, EscapedSymbolTextNodeMatcher,
// Formatting EscapedCharacterTextNodeMatcher,
ItalicBoldFormattingNodeMatcher, // Formatting
ItalicUnderlineFormattingNodeMatcher, ItalicBoldFormattingNodeMatcher,
BoldFormattingNodeMatcher, ItalicUnderlineFormattingNodeMatcher,
ItalicFormattingNodeMatcher, BoldFormattingNodeMatcher,
UnderlineFormattingNodeMatcher, ItalicFormattingNodeMatcher,
ItalicAltFormattingNodeMatcher, UnderlineFormattingNodeMatcher,
StrikethroughFormattingNodeMatcher, ItalicAltFormattingNodeMatcher,
SpoilerFormattingNodeMatcher, StrikethroughFormattingNodeMatcher,
MultiLineQuoteNodeMatcher, SpoilerFormattingNodeMatcher,
RepeatedSingleLineQuoteNodeMatcher, MultiLineQuoteNodeMatcher,
SingleLineQuoteNodeMatcher, RepeatedSingleLineQuoteNodeMatcher,
HeadingNodeMatcher, SingleLineQuoteNodeMatcher,
ListNodeMatcher, HeadingNodeMatcher,
// Code blocks ListNodeMatcher,
MultiLineCodeBlockNodeMatcher, // Code blocks
InlineCodeBlockNodeMatcher, MultiLineCodeBlockNodeMatcher,
// Mentions InlineCodeBlockNodeMatcher,
EveryoneMentionNodeMatcher, // Mentions
HereMentionNodeMatcher, EveryoneMentionNodeMatcher,
UserMentionNodeMatcher, HereMentionNodeMatcher,
ChannelMentionNodeMatcher, UserMentionNodeMatcher,
RoleMentionNodeMatcher, ChannelMentionNodeMatcher,
// Links RoleMentionNodeMatcher,
MaskedLinkNodeMatcher, // Links
AutoLinkNodeMatcher, MaskedLinkNodeMatcher,
HiddenLinkNodeMatcher, AutoLinkNodeMatcher,
// Emoji HiddenLinkNodeMatcher,
StandardEmojiNodeMatcher, // Emoji
CustomEmojiNodeMatcher, StandardEmojiNodeMatcher,
CodedStandardEmojiNodeMatcher, CustomEmojiNodeMatcher,
// Misc CodedStandardEmojiNodeMatcher,
TimestampNodeMatcher // Misc
); TimestampNodeMatcher
);
// Minimal set of matchers for non-multimedia formats (e.g. plain text) // Minimal set of matchers for non-multimedia formats (e.g. plain text)
private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
new AggregateMatcher<MarkdownNode>( new AggregateMatcher<MarkdownContext, MarkdownNode>(
// Mentions // Mentions
EveryoneMentionNodeMatcher, EveryoneMentionNodeMatcher,
HereMentionNodeMatcher, HereMentionNodeMatcher,
@@ -439,42 +459,75 @@ internal static partial class MarkdownParser
); );
private static IReadOnlyList<MarkdownNode> Parse( private static IReadOnlyList<MarkdownNode> Parse(
MarkdownContext context,
StringSegment segment, StringSegment segment,
IMatcher<MarkdownNode> matcher IMatcher<MarkdownContext, MarkdownNode> matcher
) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray(); )
{
// Limit recursion depth to a reasonable number to prevent
// stack overflow on messages with inadvertently deep nesting.
// Example: ********************************* (repeat ad nauseam)
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1214
if (context.Depth >= 32)
return [new TextNode(segment.ToString())];
return matcher
.MatchAll(
new MarkdownContext(context.Depth + 1),
segment,
(_, s) => new TextNode(s.ToString())
)
.Select(r => r.Value)
.ToArray();
}
} }
internal static partial class MarkdownParser internal static partial class MarkdownParser
{ {
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) => private static void Extract<TNode>(
Parse(segment, NodeMatcher); IEnumerable<MarkdownNode> nodes,
ICollection<TNode> extractedNodes
public static IReadOnlyList<MarkdownNode> Parse(string markdown) => )
Parse(new StringSegment(markdown)); where TNode : MarkdownNode
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
Parse(segment, MinimalNodeMatcher);
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
ParseMinimal(new StringSegment(markdown));
private static void ExtractLinks(IEnumerable<MarkdownNode> nodes, ICollection<LinkNode> links)
{ {
foreach (var node in nodes) foreach (var node in nodes)
{ {
if (node is LinkNode linkNode) if (node is TNode extractedNode)
links.Add(linkNode); extractedNodes.Add(extractedNode);
if (node is IContainerNode containerNode) if (node is IContainerNode containerNode)
ExtractLinks(containerNode.Children, links); Extract(containerNode.Children, extractedNodes);
} }
} }
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) public static IReadOnlyList<TNode> Extract<TNode>(string markdown)
where TNode : MarkdownNode
{ {
var links = new List<LinkNode>(); var extractedNodes = new List<TNode>();
ExtractLinks(Parse(markdown), links); Extract(Parse(markdown), extractedNodes);
return links; return extractedNodes;
} }
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) =>
Extract<LinkNode>(markdown);
public static IReadOnlyList<EmojiNode> ExtractEmojis(string markdown) =>
Extract<EmojiNode>(markdown);
private static IReadOnlyList<MarkdownNode> Parse(
MarkdownContext context,
StringSegment segment
) => Parse(context, segment, NodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
Parse(new MarkdownContext(), new StringSegment(markdown));
private static IReadOnlyList<MarkdownNode> ParseMinimal(
MarkdownContext context,
StringSegment segment
) => Parse(context, segment, MinimalNodeMatcher);
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
ParseMinimal(new MarkdownContext(), new StringSegment(markdown));
} }
@@ -3,9 +3,12 @@ using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> transform) : IMatcher<T> internal class RegexMatcher<TContext, TValue>(
Regex regex,
Func<TContext, StringSegment, Match, TValue?> transform
) : IMatcher<TContext, TValue>
{ {
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
{ {
var match = regex.Match(segment.Source, segment.StartIndex, segment.Length); var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
if (!match.Success) if (!match.Success)
@@ -20,8 +23,8 @@ internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> trans
return null; return null;
var segmentMatch = segment.Relocate(match); var segmentMatch = segment.Relocate(match);
var value = transform(segmentMatch, match); var value = transform(context, segmentMatch, match);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
} }
} }
@@ -2,16 +2,16 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class StringMatcher<T>( internal class StringMatcher<TContext, TValue>(
string needle, string needle,
StringComparison comparison, StringComparison comparison,
Func<StringSegment, T?> transform Func<TContext, StringSegment, TValue?> transform
) : IMatcher<T> ) : IMatcher<TContext, TValue>
{ {
public StringMatcher(string needle, Func<StringSegment, T> transform) public StringMatcher(string needle, Func<TContext, StringSegment, TValue> transform)
: this(needle, StringComparison.Ordinal, transform) { } : this(needle, StringComparison.Ordinal, transform) { }
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
{ {
var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison); var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
@@ -19,8 +19,8 @@ internal class StringMatcher<T>(
return null; return null;
var segmentMatch = segment.Relocate(index, needle.Length); var segmentMatch = segment.Relocate(index, needle.Length);
var value = transform(segmentMatch); var value = transform(context, segmentMatch);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
} }
} }
@@ -6,19 +6,19 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class AsyncCollectionExtensions public static class AsyncCollectionExtensions
{ {
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>( extension<T>(IAsyncEnumerable<T> asyncEnumerable)
this IAsyncEnumerable<T> asyncEnumerable
)
{ {
var list = new List<T>(); private async ValueTask<IReadOnlyList<T>> CollectAsync()
{
var list = new List<T>();
await foreach (var i in asyncEnumerable) await foreach (var i in asyncEnumerable)
list.Add(i); list.Add(i);
return list; return list;
}
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
asyncEnumerable.CollectAsync().GetAwaiter();
} }
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
this IAsyncEnumerable<T> asyncEnumerable
) => asyncEnumerable.CollectAsync().GetAwaiter();
} }
@@ -1,19 +0,0 @@
using System.Globalization;
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class BinaryExtensions
{
public static string ToHex(this byte[] data, bool isUpperCase = true)
{
var buffer = new StringBuilder(2 * data.Length);
foreach (var b in data)
{
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
}
return buffer.ToString();
}
}
@@ -4,25 +4,34 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class CollectionExtensions public static class CollectionExtensions
{ {
public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj) extension<T>(T obj)
{ {
yield return obj; public IEnumerable<T> ToSingletonEnumerable()
{
yield return obj;
}
} }
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source) extension<T>(IEnumerable<T> source)
{ {
var i = 0; public IEnumerable<(T value, int index)> WithIndex()
foreach (var o in source) {
yield return (o, i++); var i = 0;
foreach (var o in source)
yield return (o, i++);
}
} }
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source) extension<T>(IEnumerable<T?> source)
where T : class where T : class
{ {
foreach (var o in source) public IEnumerable<T> WhereNotNull()
{ {
if (o is not null) foreach (var o in source)
yield return o; {
if (o is not null)
yield return o;
}
} }
} }
} }
@@ -4,11 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class ColorExtensions public static class ColorExtensions
{ {
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color); extension(Color color)
{
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
public static Color ResetAlpha(this Color color) => color.WithAlpha(255); public Color ResetAlpha() => color.WithAlpha(255);
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff; public int ToRgb() => color.ToArgb() & 0xffffff;
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}"; public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
} }
@@ -5,27 +5,30 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class ExceptionExtensions public static class ExceptionExtensions
{ {
private static void PopulateChildren(this Exception exception, ICollection<Exception> children) extension(Exception exception)
{ {
if (exception is AggregateException aggregateException) private void PopulateChildren(ICollection<Exception> children)
{ {
foreach (var innerException in aggregateException.InnerExceptions) if (exception is AggregateException aggregateException)
{ {
children.Add(innerException); foreach (var innerException in aggregateException.InnerExceptions)
PopulateChildren(innerException, children); {
children.Add(innerException);
PopulateChildren(innerException, children);
}
}
else if (exception.InnerException is not null)
{
children.Add(exception.InnerException);
PopulateChildren(exception.InnerException, children);
} }
} }
else if (exception.InnerException is not null)
public IReadOnlyList<Exception> GetSelfAndChildren()
{ {
children.Add(exception.InnerException); var children = new List<Exception> { exception };
PopulateChildren(exception.InnerException, children); PopulateChildren(exception, children);
return children;
} }
} }
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
{
var children = new List<Exception> { exception };
PopulateChildren(exception, children);
return children;
}
} }
@@ -5,12 +5,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class GenericExtensions public static class GenericExtensions
{ {
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => extension<TIn>(TIn input)
transform(input); {
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
}
public static T? NullIf<T>(this T value, Func<T, bool> predicate) extension<T>(T value)
where T : struct => !predicate(value) ? value : null; where T : struct
{
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
public static T? NullIfDefault<T>(this T value) public T? NullIfDefault() =>
where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default)); value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
}
} }
@@ -4,6 +4,9 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class HttpExtensions public static class HttpExtensions
{ {
public static string? TryGetValue(this HttpHeaders headers, string name) => extension(HttpHeaders headers)
headers.TryGetValues(name, out var values) ? string.Concat(values) : null; {
public string? TryGetValue(string name) =>
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
}
} }
@@ -0,0 +1,48 @@
using System;
using System.IO;
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class PathExtensions
{
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
// which would make some additional characters invalid that are otherwise valid on Linux.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
private static readonly char[] InvalidFileNameChars =
[
'\0', // Null character - invalid on all filesystems
'/', // Path separator on Unix and Windows
'\\', // Path separator on Windows
':', // Reserved on Windows (drive letters, NTFS streams)
'*', // Wildcard on Windows
'?', // Wildcard on Windows
'"', // Reserved on Windows
'<', // Redirection on Windows
'>', // Redirection on Windows
'|', // Pipe on Windows
];
extension(Path)
{
public static string EscapeFileName(string path)
{
var buffer = new StringBuilder(path.Length);
foreach (var c in path)
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
// File names cannot end with a dot on Windows
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
if (OperatingSystem.IsWindows())
{
while (buffer.Length > 0 && buffer[^1] == '.')
buffer.Remove(buffer.Length - 1, 1);
}
return buffer.ToString();
}
}
}

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