Compare commits

...

52 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] ea0b47f26d Expand list unwrap to also resolve forum channels to thread posts
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/674ba90d-1778-4427-b50a-30bc50195319

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 20:49:33 +00:00
copilot-swe-agent[bot] f6e710909c Fix export JSON detection to use numeric check; rename GuildIds to ServerIds in GetChannelsCommand
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/ab01ba49-ad07-42ba-b382-27f4d105a92b

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 11:33:13 +00:00
copilot-swe-agent[bot] 1835af12d5 Fix: catch JsonException in list unwrap; improve export error message
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/d2a03a38-0ed4-45c7-b8e7-615ffb35c971

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 11:28:04 +00:00
copilot-swe-agent[bot] f8ab926074 Add 'list unwrap' command; remove category unwrapping from export; add [!IMPORTANT] callouts to pipeline doc sections
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/d2a03a38-0ed4-45c7-b8e7-615ffb35c971

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 11:23:45 +00:00
Oleksii Holub 7f08641974 Update error message for missing channel IDs 2026-04-04 14:16:43 +03:00
copilot-swe-agent[bot] 948ceba8cc docs: use -short|--long convention for option names in prose text
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/aa0b1344-6eb9-4853-9b48-afd06e94c970

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 11:14:19 +00:00
copilot-swe-agent[bot] b0ee4ba646 list commands always output JSON; add CliJsonSerializerContext + SnowflakeJsonConverter in CLI
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/58698f45-e22e-4bd4-aec4-31f801051467

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 09:50:29 +00:00
copilot-swe-agent[bot] 89407c121f Add .ConfigureAwait(false) to ReadLinesAsync; remove IsNullOrWhiteSpace guard
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/e94c999b-5241-4e2e-9af1-01f6be95e72c

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 09:14:41 +00:00
copilot-swe-agent[bot] 4ec8faef93 Rename 'list guilds' to 'list servers'; use 'servers' in all user-facing text; simplify token setup in docs
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/688f7e3c-af04-410d-93e7-18d7c0620672

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 09:10:11 +00:00
copilot-swe-agent[bot] 245b368396 Move Trim() from ReadLinesAsync to call site in ExportChannelsCommand
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/6c8dcb73-6a93-428b-93e5-7bbfcfd2e252

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 08:52:02 +00:00
copilot-swe-agent[bot] cbf74ba6a8 Use using System.Runtime.CompilerServices instead of fully-qualified attribute
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/a1231ba4-d235-4e2d-98b2-a5bef602a85c

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-04 08:50:06 +00:00
copilot-swe-agent[bot] f656234c38 Refine ReadLinesAsync: pattern matching, rename, cancellation token
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/e4a61491-645a-418c-91cd-74e835baaf44

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 22:26:56 +00:00
copilot-swe-agent[bot] 316e8c6ea8 Simplify stdin reading: use ReadAllLinesAsync extension and Snowflake.Parse
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/59c1113d-3c9c-4900-a9b0-5c6116ce7c75

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 16:12:47 +00:00
copilot-swe-agent[bot] 1087173fef Rename dce.sh to dce and update docs to use dce/dce.bat without extensions
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/c1f3a4c9-8af4-4b11-a961-442363e2b7be

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 16:02:36 +00:00
copilot-swe-agent[bot] a31629f722 Fix docs: put positional params before options per CliFx convention
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/41047efd-2bac-4c7a-8b26-42d9a4383281

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 15:52:29 +00:00
copilot-swe-agent[bot] 5c86beb809 Update ListCommand error message and use dce alias throughout CLI docs
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/b521468e-a2a4-4360-ab0c-847ef5e4d366

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 15:48:36 +00:00
copilot-swe-agent[bot] 55c06ef40e Add dce.sh/dce.bat alias scripts and update docs with inline DISCORD_TOKEN pipeline examples
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/98d87128-db13-4085-84c3-6290d3faffca

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 15:13:58 +00:00
copilot-swe-agent[bot] f3638ecbdd Add stub 'list' command that errors with help text when executed
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/b148b0cc-7ec8-4fa1-bbd6-36dfd116ceec

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 15:10:46 +00:00
copilot-swe-agent[bot] 0e3b455655 Restructure commands: list channels/guilds subcommands with positional parameters
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/27f23ff6-5b39-46d3-a7dc-387749ee63fa

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 15:05:43 +00:00
copilot-swe-agent[bot] 16ecb2802a Clarify DISCORD_TOKEN env var tip in documentation
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/5f305835-64af-456e-b0b4-6163ece1e8cf

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 14:46:54 +00:00
copilot-swe-agent[bot] 6750195a35 Address code review: fix Snowflake.TryParse usage and remove blank line in channels redirect block
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/5f305835-64af-456e-b0b4-6163ece1e8cf

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 14:45:14 +00:00
copilot-swe-agent[bot] efb371f093 Rework CLI commands to be composable: pipe channels to export, remove exportguild/exportall/exportdm
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/5f305835-64af-456e-b0b4-6163ece1e8cf

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-04-03 14:42:04 +00:00
copilot-swe-agent[bot] 716ea79f60 Initial plan 2026-04-03 14:36:19 +00:00
tyrrrz af3ec8c299 Use CliFx v3 stable 2026-04-03 17:27:30 +03:00
tyrrrz f1673cde77 Use the Loaded event in view init 2026-04-03 11:37:44 +03:00
tyrrrz ed33d19bc0 Fix settings getting corrupted 2026-04-03 11:20:54 +03:00
tyrrrz 80e9ad75ba Clean up 2026-04-03 00:35:09 +03:00
tyrrrz 295f4cf9a2 Improve WatchProperty 2026-04-03 00:24:27 +03:00
tyrrrz 1f189e5206 Rename view model manager methods 2026-04-02 14:50:11 +03:00
tyrrrz df683b3b41 Remove unneeded usings 2026-04-02 14:36:17 +03:00
tyrrrz d467948d0b Only execute commands from code if allowed 2026-04-02 14:36:06 +03:00
tyrrrz f6166764e9 Automate view initialization 2026-04-02 14:35:17 +03:00
dependabot[bot] 7ee2763d4b Bump the nuget group with 17 updates (#1520)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 15:03:58 +03:00
dependabot[bot] eb99ab29ed Bump the actions group with 5 updates (#1519)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-01 15:02:33 +03:00
tyrrrz 5c61306231 Update favicon 2026-03-31 23:47:26 +03:00
tyrrrz 2e4601d378 Emit source-generated files for easier debugging 2026-03-30 21:36:11 +03:00
Oleksii Holub cc703cc860 Migrate to CliFx v3 (#1516) 2026-03-26 23:53:28 +02:00
Oleksii Holub 661f82c478 Add Nix download for GUI 2026-03-24 00:51:42 +02:00
tyrrrz b9f19f7003 Fix shebang, rename script 2026-03-23 23:34:57 +02:00
Copilot e794d7c833 Replace Publish-MacOSBundle.ps1 with a .NET 10 single-file C# script (#1515)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-03-23 23:18:46 +02:00
Oleksii Holub 5a26ba0b5a Update WinGet links in Readme 2026-03-21 23:55:12 +02:00
tyrrrz 1181a35f08 Variable names consistency 2026-03-21 20:27:05 +02:00
Copilot 4a62f9c34f Add DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE env var to suppress auto-update in GUI (#1514)
* Add DISCORDCHATEXPORTER_DISABLE_UPDATE env var to disable auto-update in GUI

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/158dba86-9958-4f57-ab22-174e0606b42f

* Rename IsAutoUpdateDisabled to IsAutoUpdateAllowed and env var to DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/aa3944a4-e542-4770-8d17-2acb2c958ce9

* Refine IsAutoUpdateAllowed: group in (), invert, rename v to env, only accept false

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/1467eee2-3069-4f0b-abdf-b1dc65e00a9a

* Fix CSharpier formatting: move is { } env to indented new line

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Tyrrrz/DiscordChatExporter/sessions/7933cfe4-ecad-4697-8b9c-ee3991aa147e

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-03-21 20:25:53 +02:00
Oleksii Holub b6e1f92989 Add the GUI flavor of the AUR package to readme 2026-03-21 17:14:45 +02:00
Oleksii Holub 0d3d2ddcf0 Add more package manager install options
Added community-maintained package installation instructions for Scoop and WinGet, and clarified notes regarding MacOS and community-maintained packages.
2026-03-21 17:03:57 +02:00
billA 9afecd47bd Update token extraction script in docs (#1511) 2026-03-16 15:24:54 +02:00
Tyrrrz 5cec162b4e More consistent message box 2026-03-12 18:54:44 +02:00
Tyrrrz 6647f90ced Update default branch ref 2026-03-04 20:23:55 +02:00
Copilot b9a3001c11 Add DISCORDCHATEXPORTER_SETTINGS_PATH env var override for settings file path (#1500)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-03-02 20:06:20 +02:00
Copilot dadfe5b8ef Guard App.Dispose() against double-invocation and late shutdown (#1499)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-03-02 18:42:35 +02:00
dependabot[bot] 4f29fa63d0 Bump the actions group with 2 updates (#1495)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 15:53:28 +02:00
dependabot[bot] 54b0174bde Bump the nuget group with 15 updates (#1496)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-01 15:53:11 +02:00
68 changed files with 819 additions and 815 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ Note the `:stable` tag. DiscordChatExporter images are tagged according to the f
- `stable` — latest stable version release. This tag is updated with each release of a new project version. Recommended for personal use.
- `x.y.z` (e.g. `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `master` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `prime` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordchatexporter/tags?ordering=name).
@@ -57,7 +57,7 @@ You can also use the current working directory as the output directory by specif
- `-v $PWD:/out` in Bash
- `-v $pwd.Path:/out` in PowerShell
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
+1 -1
View File
@@ -11,7 +11,7 @@
```bash
#!/bin/bash
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
TOKEN=tokenhere
CHANNELID=channelhere
+1 -1
View File
@@ -12,7 +12,7 @@
```bash
#!/bin/bash
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
TOKEN=tokenhere
CHANNELID=channelhere
+1 -1
View File
@@ -5,7 +5,7 @@
1. Open a text editor such as Notepad and paste:
```console
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
$TOKEN = "tokenhere"
$CHANNEL = "channelhere"
+2 -2
View File
@@ -27,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
3. Type
```js
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());
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
```
into the console and press <kbd>Enter</kbd>. The console will display your user token.
@@ -120,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
1. Type
```js
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());
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
```
into the console and press <kbd>Enter</kbd>. The console will display your user token.
+116 -61
View File
@@ -23,85 +23,104 @@ Now we're ready to run the commands.
Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options.
```console
./DiscordChatExporter.Cli
./dce
```
> **Note**:
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
> On Windows, use `dce` instead of `./dce`.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## CLI commands
| Command | Description |
|-------------------------|------------------------------------------------------|
| export | Exports a channel |
| exportdm | Exports all direct message channels |
| exportguild | Exports all channels within the specified server |
| exportall | Exports all accessible channels |
| channels | Outputs the list of channels in the given server |
| dm | Outputs the list of direct message channels |
| guilds | Outputs the list of accessible servers |
| guide | Explains how to obtain token, server, and channel ID |
| Command | Description |
| ----------------- | -------------------------------------------------------------------- |
| export | Exports one or more channels |
| list channels | Outputs the list of channels in the given server(s) |
| list channels dm | Outputs the list of direct message channels |
| list servers | Outputs the list of accessible servers |
| list unwrap | Resolves categories and forums in a channel list to their child channels and threads |
| guide | Explains how to obtain token, server, and channel ID |
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./dce guide`.
To pass the token, use the `-t|--token` option:
```console
./dce export 53555 -t "mfa.Ifrn"
```
Alternatively, you can set the `DISCORD_TOKEN` environment variable and omit `-t`:
**Linux/macOS:**
```console
export DISCORD_TOKEN="mfa.Ifrn"
```
**Windows:**
```console
set DISCORD_TOKEN=mfa.Ifrn
```
The pipeline examples in this guide assume `DISCORD_TOKEN` is already set.
To get help with a specific command, run:
```console
./DiscordChatExporter.Cli command --help
./dce command --help
```
For example, to figure out how to use the `export` command, run:
```console
./DiscordChatExporter.Cli export --help
./dce export --help
```
## Export a specific channel
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 providing the channel ID as a positional argument and `-t|--token`.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
./dce export 53555 -t "mfa.Ifrn"
```
#### Changing the format
You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json` or `Csv` with `-f format`. The default
You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json` or `Csv` with `-f|--format`. The default
format is `HtmlDark`.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
./dce export 53555 -t "mfa.Ifrn" -f Json
```
#### Changing the output filename
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
You can change the filename by using `-o|--output`. e.g. for the `HTML` format:
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
./dce export 53555 -t "mfa.Ifrn" -o myserver.html
```
#### Changing the output directory
You can change the export directory by using `-o` and providing a path that ends with a slash or does not have a file
You can change the export directory by using `-o|--output` and providing a path that ends with a slash or does not have a file
extension.
If any of the folders in the path have a space in its name, escape them with quotes (").
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
./dce export 53555 -t "mfa.Ifrn" -o "C:\Discord Exports"
```
#### Changing the filename and output directory
You can change both the filename and export directory by using `-o directory\name.ext`.
You can change both the filename and export directory by using `-o|--output`.
Note that the filename must have an extension, otherwise it will be considered a directory name.
If any of the folders in the path have a space in its name, escape them with quotes (").
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
./dce export 53555 -t "mfa.Ifrn" -o "C:\Discord Exports\myserver.html"
```
#### Generating the filename and output directory dynamically
@@ -109,7 +128,7 @@ If any of the folders in the path have a space in its name, escape them with quo
You can use template tokens to generate the output file path based on the server and channel metadata.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
./dce export 53555 -t "mfa.Ifrn" -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
@@ -137,13 +156,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.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
./dce export 53555 -t "mfa.Ifrn" -p 10
```
A 45 MB channel set to be partitioned every 20 MB will output 3 files.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
./dce export 53555 -t "mfa.Ifrn" -p 20mb
```
#### Downloading assets
@@ -154,7 +173,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.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
./dce export 53555 -t "mfa.Ifrn" --media
```
#### Reusing assets
@@ -163,7 +182,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.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
./dce export 53555 -t "mfa.Ifrn" --media --reuse-media
```
#### Changing the media directory
@@ -172,7 +191,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.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
./dce export 53555 -t "mfa.Ifrn" --media --media-dir "C:\Discord Media"
```
#### Changing the date format
@@ -181,7 +200,7 @@ You can customize how dates are formatted in the exported files by using `--loca
locales. The default locale is `en-US`.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
./dce export 53555 -t "mfa.Ifrn" --locale "de-DE"
```
#### Date ranges
@@ -190,14 +209,14 @@ locales. The default locale is `en-US`.
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
./dce export 53555 -t "mfa.Ifrn" --before 2019-09-18
```
**Messages sent after a date**
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
./dce export 53555 -t "mfa.Ifrn" --after "2019-09-17 23:34"
```
**Messages sent in a date range**
@@ -205,7 +224,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
September 17th, 2019 11:34 PM and September 18th:
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
./dce export 53555 -t "mfa.Ifrn" --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
@@ -219,75 +238,111 @@ formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custo
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"
./dce export 53555 -t "mfa.Ifrn" --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).
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs/Message-filters.md).
### 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:
> [!IMPORTANT]
> The following examples assume `DISCORD_TOKEN` is already set. See [CLI commands](#cli-commands) for instructions.
To export all channels in a specific server, use `list channels` to list channels and pipe the result to `export`.
**Linux/macOS:**
```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
./dce list channels 21814 | ./dce export
```
**Windows:**
```console
dce list channels 21814 | dce export
```
You can also list channels for multiple servers at once:
```console
./dce list channels 21814 35930 | ./dce export
```
#### Including threads
By default, threads are not included in the export. You can change this behavior by using `--include-threads` and
specifying which threads should be included. It has possible values of `none`, `active`, or `all`, indicating which
threads should be included. To include both active and archived threads, use `--include-threads all`.
By default, threads are not included. You can change this behavior by passing `--include-threads` to the `list channels` command. It has possible values of `none`, `active`, or `all`, indicating which threads should be included. To include both active and archived threads, use `--include-threads all`.
```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
./dce list channels 21814 --include-threads all | ./dce export
```
#### Including voice channels
By default, voice channels are included in the export. You can change this behavior by using `--include-vc` and
specifying whether to include voice channels in the export. It has possible values of `true` or `false`, to exclude
voice channels, use `--include-vc false`.
By default, voice channels are included. You can change this behavior by passing `--include-vc false` to the `list channels` command.
```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
./dce list channels 21814 --include-vc false | ./dce export
```
### Export all channels
### Export all DMs
To export all accessible channels, use the `exportall` command:
> [!IMPORTANT]
> The following examples assume `DISCORD_TOKEN` is already set. See [CLI commands](#cli-commands) for instructions.
To export all DMs:
**Linux/macOS:**
```console
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
./dce list channels dm | ./dce export
```
#### Excluding DMs
To exclude DMs, add the `--include-dm false` option.
**Windows:**
```console
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
dce list channels dm | dce export
```
### List channels in a server
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 `list channels` command and provide the server ID as an argument:
```console
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
./dce list channels 21814 -t "mfa.Ifrn"
```
The `list channels` command outputs a JSON array of channel objects. You can pipe this directly to the `export` command:
```console
./dce list channels 21814 | ./dce export
```
### List direct message channels
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 `list channels dm` command:
```console
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
./dce list channels dm -t "mfa.Ifrn"
```
The `list channels dm` command outputs a JSON array of channel objects. You can pipe this directly to the `export` command:
```console
./dce list channels dm | ./dce export
```
### Unwrap categories and forums
To resolve category and forum channels in a list to their child channels and thread posts, use the `list unwrap` command:
```console
./dce list channels 21814 | ./dce list unwrap | ./dce export
```
### List servers
To list all servers accessible by the current account, use the `guilds` command:
To list all servers accessible by the current account, use the `list servers` command:
```console
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
./dce list servers -t "mfa.Ifrn" > C:\path\to\output.txt
```
+2 -2
View File
@@ -1,10 +1,10 @@
blank_issues_enabled: false
contact_links:
- name: ⚠ Feature request
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
- name: 📖 Documentation
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
about: Find usage guides and frequently asked questions.
- name: 🗨 Discussions
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
+7 -7
View File
@@ -4,12 +4,12 @@ on:
workflow_dispatch:
push:
branches:
- master
- prime
tags:
- "*"
pull_request:
branches:
- master
- prime
jobs:
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build image
run: >
@@ -37,15 +37,15 @@ jobs:
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload image
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar
if-no-files-found: error
deploy:
# Deploy to DockerHub only on tag push or master branch push
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'master' }}
# Deploy to DockerHub only on tag push or prime branch push
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'prime' }}
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -58,7 +58,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to DockerHub
run: >
+9 -9
View File
@@ -4,12 +4,12 @@ on:
workflow_dispatch:
push:
branches:
- master
- prime
tags:
- "*"
pull_request:
branches:
- master
- prime
env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
@@ -29,7 +29,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
# Build the project separately to discern between build and format errors
- name: Build
@@ -65,7 +65,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
- name: Run tests
env:
@@ -81,7 +81,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -120,7 +120,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
- name: Publish app
run: >
@@ -135,7 +135,7 @@ jobs:
--self-contained
- name: Upload app binaries
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/
@@ -200,7 +200,7 @@ jobs:
steps:
- name: Download app binaries
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/
@@ -236,7 +236,7 @@ jobs:
steps:
- name: Notify Discord
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
uses: tyrrrz/action-http-request@25f132e48dea89c0f6b7955398270b506e1d51cb # 1.1.4
with:
url: ${{ secrets.DISCORD_WEBHOOK }}
method: POST
+2 -3
View File
@@ -1,5 +1,4 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>999.9.9-dev</Version>
@@ -7,8 +6,8 @@
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
</Project>
</Project>
+25 -22
View File
@@ -5,39 +5,42 @@
<ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.4.0" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
<PackageVersion Include="AsyncKeyedLock" Version="8.0.1" />
<PackageVersion Include="Avalonia" Version="11.3.11" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.11" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.11" />
<PackageVersion Include="CliFx" Version="2.3.6" />
<PackageVersion Include="Cogwheel" Version="2.1.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.5" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.7.0" />
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
<PackageVersion Include="Avalonia" Version="11.3.13" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.13" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.13" />
<PackageVersion Include="CliFx" Version="3.0.0" />
<PackageVersion Include="Cogwheel" Version="2.1.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
<PackageVersion Include="Deorcify" Version="1.1.0" />
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.11.0" />
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.3" />
<PackageVersion Include="Gress" Version="2.1.1" />
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
<PackageVersion Include="Markdig" Version="1.0.0" />
<PackageVersion Include="Markdig" Version="1.1.2" />
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.5" />
<PackageVersion
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
Version="10.0.5"
/>
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="Onova" Version="2.6.13" />
<PackageVersion Include="Polly" Version="8.6.5" />
<PackageVersion Include="Polly" Version="8.6.6" />
<PackageVersion Include="RazorBlade" Version="0.11.0" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="Superpower" Version="3.1.0" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="YoutubeExplode" Version="6.5.6" />
<PackageVersion Include="YoutubeExplode" Version="6.5.7" />
</ItemGroup>
</Project>
@@ -2,7 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
@@ -17,23 +17,22 @@ public abstract class DiscordCommandBase : ICommand
EnvironmentVariable = "DISCORD_TOKEN",
Description = "Authentication token."
)]
public required string Token { get; init; }
public required string Token { get; set; }
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
[CommandOption(
"bot",
'b',
EnvironmentVariable = "DISCORD_TOKEN_BOT",
Description = "This option doesn't do anything. Kept for backwards compatibility."
)]
public bool IsBotToken { get; init; } = false;
public bool IsBotToken { get; set; } = false;
[CommandOption(
"respect-rate-limits",
Description = "Whether to respect advisory rate limits. "
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
)]
public bool ShouldRespectRateLimits { get; init; } = true;
public bool ShouldRespectRateLimits { get; set; } = true;
[field: AllowNull, MaybeNull]
protected DiscordClient Discord =>
@@ -5,8 +5,8 @@ using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
@@ -37,23 +37,23 @@ public abstract class ExportCommandBase : DiscordCommandBase
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => field = Path.GetFullPath(value);
set => field = Path.GetFullPath(value);
} = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
[CommandOption(
"after",
Description = "Only include messages sent after this date or message ID."
)]
public Snowflake? After { get; init; }
public Snowflake? After { get; set; }
[CommandOption(
"before",
Description = "Only include messages sent before this date or message ID."
)]
public Snowflake? Before { get; init; }
public Snowflake? Before { get; set; }
[CommandOption(
"partition",
@@ -61,51 +61,51 @@ public abstract class ExportCommandBase : DiscordCommandBase
Description = "Split the output into partitions, each limited to the specified "
+ "number of messages (e.g. '100') or file size (e.g. '10mb')."
)]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
public PartitionLimit PartitionLimit { get; set; } = PartitionLimit.Null;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
Converter = typeof(ThreadInclusionModeInputConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
[CommandOption(
"filter",
Description = "Only include messages that satisfy this filter. "
+ "See the documentation for more info."
)]
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
public MessageFilter MessageFilter { get; set; } = MessageFilter.Null;
[CommandOption(
"parallel",
Description = "Limits how many channels can be exported in parallel."
)]
public int ParallelLimit { get; init; } = 1;
public int ParallelLimit { get; set; } = 1;
[CommandOption(
"reverse",
Description = "Export messages in reverse chronological order (newest first)."
)]
public bool IsReverseMessageOrder { get; init; }
public bool IsReverseMessageOrder { get; set; }
[CommandOption(
"markdown",
Description = "Process markdown, mentions, and other special tokens."
)]
public bool ShouldFormatMarkdown { get; init; } = true;
public bool ShouldFormatMarkdown { get; set; } = true;
[CommandOption(
"media",
Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
)]
public bool ShouldDownloadAssets { get; init; }
public bool ShouldDownloadAssets { get; set; }
[CommandOption(
"reuse-media",
Description = "Reuse previously downloaded assets to avoid redundant requests."
)]
public bool ShouldReuseAssets { get; init; } = false;
public bool ShouldReuseAssets { get; set; } = false;
[CommandOption(
"media-dir",
@@ -117,34 +117,33 @@ public abstract class ExportCommandBase : DiscordCommandBase
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => field = value is not null ? Path.GetFullPath(value) : null;
set => field = value is not null ? Path.GetFullPath(value) : null;
}
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
[CommandOption(
"dateformat",
Description = "This option doesn't do anything. Kept for backwards compatibility."
)]
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
public string DateFormat { get; set; } = "MM/dd/yyyy h:mm tt";
[CommandOption(
"locale",
Description = "Locale to use when formatting dates and numbers. "
+ "If not specified, the default system locale will be used."
)]
public string? Locale { get; init; }
public string? Locale { get; set; }
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
public bool IsUtcNormalizationEnabled { get; init; } = false;
public bool IsUtcNormalizationEnabled { get; set; } = false;
[CommandOption(
"fuck-russia",
EnvironmentVariable = "FUCK_RUSSIA",
Description = "Don't print the Support Ukraine message to the console.",
// Use a converter to accept '1' as 'true' to reuse the existing environment variable
Converter = typeof(TruthyBooleanBindingConverter)
Converter = typeof(TruthyBooleanInputConverter)
)]
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
public bool IsUkraineSupportMessageDisabled { get; set; } = false;
[field: AllowNull, MaybeNull]
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
@@ -1,10 +1,10 @@
using System;
using CliFx.Extensibility;
using CliFx.Activation;
using DiscordChatExporter.Cli.Commands.Shared;
namespace DiscordChatExporter.Cli.Commands.Converters;
internal class ThreadInclusionModeBindingConverter : BindingConverter<ThreadInclusionMode>
internal class ThreadInclusionModeInputConverter : ScalarInputConverter<ThreadInclusionMode>
{
public override ThreadInclusionMode Convert(string? rawValue)
{
@@ -1,9 +1,9 @@
using System.Globalization;
using CliFx.Extensibility;
using CliFx.Activation;
namespace DiscordChatExporter.Cli.Commands.Converters;
internal class TruthyBooleanBindingConverter : BindingConverter<bool>
internal class TruthyBooleanInputConverter : ScalarInputConverter<bool>
{
public override bool Convert(string? rawValue)
{
@@ -1,156 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Dump;
using DiscordChatExporter.Core.Exceptions;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportall", Description = "Exports all accessible channels.")]
public class ExportAllCommand : ExportCommandBase
{
[CommandOption("include-dm", Description = "Include direct message channels.")]
public bool IncludeDirectChannels { get; init; } = true;
[CommandOption("include-guilds", Description = "Include server channels.")]
public bool IncludeGuildChannels { get; init; } = true;
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. "
+ "If provided, only channels referenced in the dump will be exported."
)]
public string? DataPackageFilePath { get; init; }
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
// Pull from the API
if (string.IsNullOrWhiteSpace(DataPackageFilePath))
{
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
{
// Regular channels
await console.Output.WriteLineAsync(
$"Fetching channels for server '{guild.Name}'..."
);
var fetchedChannelsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var channel in Discord.GetGuildChannelsAsync(
guild.Id,
cancellationToken
)
)
{
if (channel.IsCategory)
continue;
if (!IncludeVoiceChannels && channel.IsVoice)
continue;
channels.Add(channel);
ctx.Status(
Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'.")
);
fetchedChannelsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
}
}
// Pull from the data package
else
{
await console.Output.WriteLineAsync("Extracting channels...");
var dump = await DataDump.LoadAsync(DataPackageFilePath, cancellationToken);
var inaccessibleChannels = new List<DataDumpChannel>();
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
foreach (var dumpChannel in dump.Channels)
{
ctx.Status(
Markup.Escape(
$"Fetching '{dumpChannel.Name}' ({dumpChannel.Id})..."
)
);
try
{
var channel = await Discord.GetChannelAsync(
dumpChannel.Id,
cancellationToken
);
channels.Add(channel);
}
catch (DiscordChatExporterException)
{
inaccessibleChannels.Add(dumpChannel);
}
}
}
);
await console.Output.WriteLineAsync($"Fetched {channels} channel(s).");
// Print inaccessible channels
if (inaccessibleChannels.Any())
{
await console.Output.WriteLineAsync();
using (console.WithForegroundColor(ConsoleColor.Red))
{
await console.Error.WriteLineAsync(
"Failed to access the following channel(s):"
);
}
foreach (var dumpChannel in inaccessibleChannels)
await console.Error.WriteLineAsync($"{dumpChannel.Name} ({dumpChannel.Id})");
await console.Error.WriteLineAsync();
}
}
// Filter out unwanted channels
if (!IncludeDirectChannels)
channels.RemoveAll(c => c.IsDirect);
if (!IncludeGuildChannels)
channels.RemoveAll(c => c.IsGuild);
if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.IsVoice);
await ExportAsync(console, channels);
}
}
@@ -1,25 +1,27 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("export", Description = "Exports one or multiple channels.")]
public class ExportChannelsCommand : ExportCommandBase
public partial class ExportChannelsCommand : ExportCommandBase
{
// TODO: change this to plural (breaking change)
[CommandOption(
"channel",
'c',
[CommandParameter(
0,
Name = "channel-ids",
Description = "Channel ID(s). "
+ "If provided with category ID(s), all channels inside those categories will be exported."
+ "If not provided, channel IDs are read from standard input (one per line or as a JSON array), "
+ "enabling piping from the 'list channels' or 'list channels dm' commands."
)]
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
public IReadOnlyList<Snowflake> ChannelIds { get; set; } = [];
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -27,35 +29,46 @@ public class ExportChannelsCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler();
// If no channel IDs were specified, read them from stdin
var channelIds = new List<Snowflake>(ChannelIds);
if (channelIds.Count == 0 && console.IsInputRedirected)
{
await foreach (var line in console.Input.ReadLinesAsync(cancellationToken))
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed))
continue;
// Snowflake IDs are numeric; non-numeric input is treated as a JSON array
if (!char.IsAsciiDigit(trimmed[0]))
{
using var doc = JsonDocument.Parse(trimmed);
foreach (var element in doc.RootElement.EnumerateArray())
channelIds.Add(Snowflake.Parse(element.GetProperty("id").GetString()!));
}
else
{
channelIds.Add(Snowflake.Parse(trimmed));
}
}
}
if (channelIds.Count == 0)
{
throw new CommandException(
"No channel IDs provided. "
+ "Specify channel IDs as arguments or pipe them from the 'list channels' or 'list channels dm' commands."
);
}
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in ChannelIds)
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);
}
channels.Add(channel);
}
await ExportAsync(console, channels);
@@ -1,27 +0,0 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportdm", Description = "Exports all direct message channels.")]
public class ExportDirectMessagesCommand : ExportCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(
Guild.DirectMessages.Id,
cancellationToken
);
await ExportAsync(console, channels);
}
}
@@ -1,61 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Exports all channels within the specified server.")]
public class ExportGuildCommand : ExportCommandBase
{
[CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; }
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
await console.Output.WriteLineAsync("Fetching channels...");
var fetchedChannelsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var channel in Discord.GetGuildChannelsAsync(GuildId, cancellationToken)
)
{
if (channel.IsCategory)
continue;
if (!IncludeVoiceChannels && channel.IsVoice)
continue;
channels.Add(channel);
ctx.Status(Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'."));
fetchedChannelsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
await ExportAsync(console, channels);
}
}
@@ -1,31 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Json;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("channels", Description = "Get the list of channels in a server.")]
public class GetChannelsCommand : DiscordCommandBase
[Command("list channels", Description = "Gets the list of channels in one or more servers.")]
public partial class GetChannelsCommand : DiscordCommandBase
{
[CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; }
[CommandParameter(0, Name = "server-ids", Description = "Server ID(s).")]
public required IReadOnlyList<Snowflake> ServerIds { get; set; }
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
public bool IncludeVoiceChannels { get; set; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
Converter = typeof(ThreadInclusionModeInputConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -33,82 +36,44 @@ public class GetChannelsCommand : DiscordCommandBase
var cancellationToken = console.RegisterCancellationHandler();
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => !c.IsCategory)
.Where(c => IncludeVoiceChannels || !c.IsVoice)
.OrderBy(c => c.Parent?.Position)
.ThenBy(c => c.Name)
.ToArray();
var allChannels = new List<Channel>();
var channelIdMaxLength = channels
.Select(c => c.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
var threads =
ThreadInclusionMode != ThreadInclusionMode.None
? (
await Discord.GetGuildThreadsAsync(
GuildId,
ThreadInclusionMode == ThreadInclusionMode.All,
null,
null,
cancellationToken
)
)
.OrderBy(c => c.Name)
.ToArray()
: [];
foreach (var channel in channels)
foreach (var serverId in ServerIds)
{
// Channel ID
await console.Output.WriteAsync(
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
);
var channels = (await Discord.GetGuildChannelsAsync(serverId, cancellationToken))
.Where(c => !c.IsCategory)
.Where(c => IncludeVoiceChannels || !c.IsVoice)
.OrderBy(c => c.Parent?.Position)
.ThenBy(c => c.Name)
.ToArray();
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
var threads =
ThreadInclusionMode != ThreadInclusionMode.None
? (
await Discord.GetGuildThreadsAsync(
serverId,
ThreadInclusionMode == ThreadInclusionMode.All,
null,
null,
cancellationToken
)
)
.OrderBy(c => c.Name)
.ToArray()
: [];
// Channel name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(channel.GetHierarchicalName());
var channelThreads = threads.Where(t => t.Parent?.Id == channel.Id).ToArray();
var channelThreadIdMaxLength = channelThreads
.Select(t => t.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var channelThread in channelThreads)
foreach (var channel in channels)
{
// Indent
await console.Output.WriteAsync(" * ");
// Thread ID
await console.Output.WriteAsync(
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
);
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Thread name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteAsync($"Thread / {channelThread.Name}");
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Thread status
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(
channelThread.IsArchived ? "Archived" : "Active"
);
allChannels.Add(channel);
allChannels.AddRange(threads.Where(t => t.Parent?.Id == channel.Id));
}
}
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(
allChannels.ToArray(),
CliJsonSerializerContext.Instance.ChannelArray
)
);
}
}
@@ -1,16 +1,17 @@
using System;
using System.Linq;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Json;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("dm", Description = "Gets the list of all direct message channels.")]
public class GetDirectChannelsCommand : DiscordCommandBase
[Command("list channels dm", Description = "Gets the list of direct message channels.")]
public partial class GetDirectChannelsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -25,25 +26,8 @@ public class GetDirectChannelsCommand : DiscordCommandBase
.ThenBy(c => c.Name)
.ToArray();
var channelIdMaxLength = channels
.Select(c => c.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var channel in channels)
{
// Channel ID
await console.Output.WriteAsync(
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
);
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Channel name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(channel.GetHierarchicalName());
}
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(channels, CliJsonSerializerContext.Instance.ChannelArray)
);
}
}
@@ -1,16 +1,17 @@
using System;
using System.Linq;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Json;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("guilds", Description = "Gets the list of accessible servers.")]
public class GetGuildsCommand : DiscordCommandBase
[Command("list servers", Description = "Gets the list of accessible servers.")]
public partial class GetGuildsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -24,23 +25,8 @@ public class GetGuildsCommand : DiscordCommandBase
.ThenBy(g => g.Name)
.ToArray();
var guildIdMaxLength = guilds
.Select(g => g.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var guild in guilds)
{
// Guild ID
await console.Output.WriteAsync(guild.Id.ToString().PadRight(guildIdMaxLength, ' '));
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Guild name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(guild.Name);
}
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(guilds, CliJsonSerializerContext.Instance.GuildArray)
);
}
}
@@ -1,13 +1,13 @@
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Binding;
using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands;
[Command("guide", Description = "Explains how to obtain the token, server or channel ID.")]
public class GuideCommand : ICommand
public partial class GuideCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
@@ -74,7 +74,7 @@ public class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
{
console.Output.WriteLine(
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
"https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs"
);
}
@@ -0,0 +1,13 @@
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands;
[Command("list", Description = "Lists channels, DMs, or servers.")]
public partial class ListCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) =>
throw new CommandException("Use one of the named commands listed below.", showHelp: true);
}
@@ -0,0 +1,95 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Cli.Utils.Json;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command(
"list unwrap",
Description = "Resolves categories and forums in a channel list to their child channels and threads."
)]
public partial class UnwrapChannelsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
// Read all JSON from stdin (produced by 'list channels' or 'list channels dm')
var sb = new StringBuilder();
await foreach (var line in console.Input.ReadLinesAsync(cancellationToken))
sb.Append(line);
Channel[] channels;
try
{
channels =
JsonSerializer.Deserialize(
sb.ToString().Trim(),
CliJsonSerializerContext.Instance.ChannelArray
) ?? [];
}
catch (JsonException)
{
throw new CommandException(
"Failed to parse input as a JSON channel array. "
+ "Pipe the output of 'list channels' or 'list channels dm' to this command."
);
}
var result = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channel in channels)
{
if (channel.IsCategory)
{
// Expand category to its child channels
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
result.Add(guildChannel);
}
channelsByGuild[channel.GuildId] = guildChannels;
}
else if (channel.Kind == ChannelKind.GuildForum)
{
// Expand forum to its thread posts
await foreach (
var thread in Discord.GetChannelThreadsAsync(
[channel],
cancellationToken: cancellationToken
)
)
result.Add(thread);
}
else
{
result.Add(channel);
}
}
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(
result.ToArray(),
CliJsonSerializerContext.Instance.ChannelArray
)
);
}
}
@@ -6,12 +6,6 @@
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup>
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
<PropertyGroup>
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
<EnableAotAnalyzer>false</EnableAotAnalyzer>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" />
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
@@ -23,4 +17,17 @@
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None
Include="dce"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
/>
<None
Include="dce.bat"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
/>
</ItemGroup>
</Project>
+3 -31
View File
@@ -1,41 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
namespace DiscordChatExporter.Cli;
public static class Program
{
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
[DynamicDependency(
DynamicallyAccessedMemberTypes.All,
typeof(ThreadInclusionModeBindingConverter)
)]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
public static async Task<int> Main(string[] args) =>
await new CliApplicationBuilder()
.AddCommand<ExportAllCommand>()
.AddCommand<ExportChannelsCommand>()
.AddCommand<ExportDirectMessagesCommand>()
.AddCommand<ExportGuildCommand>()
.AddCommand<GetChannelsCommand>()
.AddCommand<GetDirectChannelsCommand>()
.AddCommand<GetGuildsCommand>()
.AddCommand<GuideCommand>()
await new CommandLineApplicationBuilder()
.AddCommandsFromThisAssembly()
.Build()
.RunAsync(args);
}
@@ -1,4 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using Spectre.Console;
@@ -61,4 +65,15 @@ internal static class ConsoleExtensions
progressTask.StopTask();
}
}
public static async IAsyncEnumerable<string> ReadLinesAsync(
this TextReader reader,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
yield return line;
}
}
}
@@ -0,0 +1,26 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Cli.Utils.Json;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
GenerationMode = JsonSourceGenerationMode.Metadata
)]
[JsonSerializable(typeof(Channel[]))]
[JsonSerializable(typeof(Guild[]))]
internal partial class CliJsonSerializerContext : JsonSerializerContext
{
// Instance pre-configured with converters for Snowflake (serialised as a string)
// and all enum types (serialised as their name). Defined here so the Core types
// are never touched.
public static CliJsonSerializerContext Instance { get; } =
new(
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new SnowflakeJsonConverter(), new JsonStringEnumConverter() },
}
);
}
@@ -0,0 +1,21 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Utils.Json;
internal class SnowflakeJsonConverter : JsonConverter<Snowflake>
{
public override Snowflake Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
) => Snowflake.Parse(reader.GetString()!);
public override void Write(
Utf8JsonWriter writer,
Snowflake value,
JsonSerializerOptions options
) => writer.WriteStringValue(value.ToString());
}
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
exec "$(dirname "$0")/DiscordChatExporter.Cli" "$@"
+2
View File
@@ -0,0 +1,2 @@
@echo off
"%~dp0DiscordChatExporter.Cli.exe" %*
@@ -1,12 +1,10 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting;
+36 -20
View File
@@ -12,7 +12,6 @@ using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.Views;
using Material.Styles.Themes;
using Microsoft.Extensions.DependencyInjection;
@@ -20,11 +19,12 @@ namespace DiscordChatExporter.Gui;
public class App : Application, IDisposable
{
private readonly DisposableCollector _eventRoot = new();
private readonly ServiceProvider _services;
private readonly SettingsService _settingsService;
private readonly MainViewModel _mainViewModel;
private readonly DisposableCollector _eventRoot = new();
private bool _isDisposed;
public App()
{
@@ -52,15 +52,14 @@ public class App : Application, IDisposable
_services = services.BuildServiceProvider(true);
_settingsService = _services.GetRequiredService<SettingsService>();
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
// Re-initialize the theme when the user changes it
_eventRoot.Add(
_settingsService.WatchProperty(
o => o.Theme,
() =>
v =>
{
RequestedThemeVariant = _settingsService.Theme switch
RequestedThemeVariant = v switch
{
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
@@ -73,13 +72,6 @@ public class App : Application, IDisposable
);
}
public override void Initialize()
{
base.Initialize();
AvaloniaXamlLoader.Load(this);
}
private void InitializeTheme()
{
var actualTheme = RequestedThemeVariant?.Key switch
@@ -95,18 +87,37 @@ public class App : Application, IDisposable
: Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825"));
}
public override void Initialize()
{
base.Initialize();
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
base.OnFrameworkInitializationCompleted();
// Set up custom theme colors
// Initialize the default theme, before a custom one (if any) is applied by loading settings
InitializeTheme();
// Load settings
_settingsService.Load();
// Initialize and configure the main window
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var viewManager = _services.GetRequiredService<ViewManager>();
var viewModelManager = _services.GetRequiredService<ViewModelManager>();
desktop.MainWindow = viewManager.TryBindWindow(viewModelManager.GetMainViewModel());
// Although `App.Dispose()` is invoked from `Program.Main(...)`, on some platforms
// it may be called too late in the shutdown lifecycle. Attach an exit
// handler to ensure timely disposal as a safeguard.
// https://github.com/Tyrrrz/YoutubeDownloader/issues/795
desktop.Exit += (_, _) => Dispose();
}
base.OnFrameworkInitializationCompleted();
}
private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
@@ -115,6 +126,11 @@ public class App : Application, IDisposable
public void Dispose()
{
if (_isDisposed)
return;
_isDisposed = true;
_eventRoot.Dispose();
_services.Dispose();
}
@@ -21,12 +21,6 @@
<PublishMacOSBundle>false</PublishMacOSBundle>
</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>
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
</ItemGroup>
@@ -56,7 +50,7 @@
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
<Exec
Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)"
Command="dotnet run --file &quot;$(ProjectDir)PublishMacOSBundle.csx&quot; -- --publish-dir &quot;$(PublishDir)&quot; --icons-file &quot;$(ProjectDir)../favicon.icns&quot; --full-version $(Version) --short-version $(AssemblyVersion)"
LogStandardErrorAsError="true"
/>
</Target>
@@ -29,9 +29,16 @@ public partial class ViewManager
return null;
view.DataContext ??= viewModel;
view.Loaded += async (_, _) => await viewModel.InitializeAsync();
return view;
}
public UserControl<T>? TryBindUserControl<T>(T viewModel)
where T : ViewModelBase => TryBindView(viewModel) as UserControl<T>;
public Window<T>? TryBindWindow<T>(T viewModel)
where T : ViewModelBase => TryBindView(viewModel) as Window<T>;
}
public partial class ViewManager : IDataTemplate
@@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DiscordChatExporter.Gui.Framework;
@@ -9,6 +10,8 @@ public abstract class ViewModelBase : ObservableObject, IDisposable
protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty);
public virtual Task InitializeAsync() => Task.CompletedTask;
protected virtual void Dispose(bool disposing) { }
public void Dispose()
@@ -10,12 +10,12 @@ namespace DiscordChatExporter.Gui.Framework;
public class ViewModelManager(IServiceProvider services)
{
public MainViewModel CreateMainViewModel() => services.GetRequiredService<MainViewModel>();
public MainViewModel GetMainViewModel() => services.GetRequiredService<MainViewModel>();
public DashboardViewModel CreateDashboardViewModel() =>
public DashboardViewModel GetDashboardViewModel() =>
services.GetRequiredService<DashboardViewModel>();
public ExportSetupViewModel CreateExportSetupViewModel(
public ExportSetupViewModel GetExportSetupViewModel(
Guild guild,
IReadOnlyList<Channel> channels
)
@@ -28,7 +28,7 @@ public class ViewModelManager(IServiceProvider services)
return viewModel;
}
public MessageBoxViewModel CreateMessageBoxViewModel(
public MessageBoxViewModel GetMessageBoxViewModel(
string title,
string message,
string? okButtonText,
@@ -45,9 +45,9 @@ public class ViewModelManager(IServiceProvider services)
return viewModel;
}
public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) =>
CreateMessageBoxViewModel(title, message, "CLOSE", null);
public MessageBoxViewModel GetMessageBoxViewModel(string title, string message) =>
GetMessageBoxViewModel(title, message, "CLOSE", null);
public SettingsViewModel CreateSettingsViewModel() =>
public SettingsViewModel GetSettingsViewModel() =>
services.GetRequiredService<SettingsViewModel>();
}
@@ -42,7 +42,7 @@ public partial class LocalizationManager
* Your bot needs to have the **Message Content Intent** enabled to read messages
""",
[nameof(TokenHelpText)] =
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
// Settings
[nameof(SettingsTitle)] = "Settings",
[nameof(ThemeLabel)] = "Theme",
@@ -44,7 +44,7 @@ public partial class LocalizationManager
* Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages
""",
[nameof(TokenHelpText)] =
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
// Settings
[nameof(SettingsTitle)] = "Paramètres",
[nameof(ThemeLabel)] = "Thème",
@@ -44,7 +44,7 @@ public partial class LocalizationManager
* Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen
""",
[nameof(TokenHelpText)] =
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
// Settings
[nameof(SettingsTitle)] = "Einstellungen",
[nameof(ThemeLabel)] = "Design",
@@ -42,7 +42,7 @@ public partial class LocalizationManager
* Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes
""",
[nameof(TokenHelpText)] =
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
// Settings
[nameof(SettingsTitle)] = "Ajustes",
[nameof(ThemeLabel)] = "Tema",
@@ -42,7 +42,7 @@ public partial class LocalizationManager
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
""",
[nameof(TokenHelpText)] =
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
// Settings
[nameof(SettingsTitle)] = "Налаштування",
[nameof(ThemeLabel)] = "Тема",
@@ -14,18 +14,12 @@ public partial class LocalizationManager : ObservableObject, IDisposable
public LocalizationManager(SettingsService settingsService)
{
_eventRoot.Add(
settingsService.WatchProperty(
o => o.Language,
() => Language = settingsService.Language,
true
)
);
_eventRoot.Add(settingsService.WatchProperty(o => o.Language, v => Language = v, true));
_eventRoot.Add(
this.WatchProperty(
o => o.Language,
() =>
_ =>
{
foreach (var propertyName in EnglishLocalization.Keys)
OnPropertyChanged(propertyName);
+1 -1
View File
@@ -21,7 +21,7 @@ public static class Program
public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/prime/.docs";
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
@@ -1,87 +0,0 @@
param(
[Parameter(Mandatory=$true)]
[string]$PublishDirPath,
[Parameter(Mandatory=$true)]
[string]$IconsFilePath,
[Parameter(Mandatory=$true)]
[string]$FullVersion,
[Parameter(Mandatory=$true)]
[string]$ShortVersion
)
$ErrorActionPreference = "Stop"
# Setup paths
$tempDirPath = Join-Path $PublishDirPath "../publish-macos-app-temp"
$bundleName = "DiscordChatExporter.app"
$bundleDirPath = Join-Path $tempDirPath $bundleName
$contentsDirPath = Join-Path $bundleDirPath "Contents"
$macosDirPath = Join-Path $contentsDirPath "MacOS"
$resourcesDirPath = Join-Path $contentsDirPath "Resources"
try {
# Initialize the bundle's directory structure
New-Item -Path $bundleDirPath -ItemType Directory -Force
New-Item -Path $contentsDirPath -ItemType Directory -Force
New-Item -Path $macosDirPath -ItemType Directory -Force
New-Item -Path $resourcesDirPath -ItemType Directory -Force
# Copy icons into the .app's Resources folder
Copy-Item -Path $IconsFilePath -Destination (Join-Path $resourcesDirPath "AppIcon.icns") -Force
# Generate the Info.plist metadata file with the app information
$plistContent = @"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>DiscordChatExporter</string>
<key>CFBundleName</key>
<string>DiscordChatExporter</string>
<key>CFBundleExecutable</key>
<string>DiscordChatExporter</string>
<key>NSHumanReadableCopyright</key>
<string>© Oleksii Holub</string>
<key>CFBundleIdentifier</key>
<string>me.Tyrrrz.DiscordChatExporter</string>
<key>CFBundleSpokenName</key>
<string>Discord Chat Exporter</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleVersion</key>
<string>$FullVersion</string>
<key>CFBundleShortVersionString</key>
<string>$ShortVersion</string>
<key>NSHighResolutionCapable</key>
<true />
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
"@
Set-Content -Path (Join-Path $contentsDirPath "Info.plist") -Value $plistContent
# Delete the previous bundle if it exists
if (Test-Path (Join-Path $PublishDirPath $bundleName)) {
Remove-Item -Path (Join-Path $PublishDirPath $bundleName) -Recurse -Force
}
# Move all files from the publish directory into the MacOS directory
Get-ChildItem -Path $PublishDirPath | ForEach-Object {
Move-Item -Path $_.FullName -Destination $macosDirPath -Force
}
# Move the final bundle into the publish directory for upload
Move-Item -Path $bundleDirPath -Destination $PublishDirPath -Force
}
finally {
# Clean up the temporary directory
Remove-Item -Path $tempDirPath -Recurse -Force
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/dotnet --
#:package CliFx
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
[Command(Description = "Publishes the GUI app as a macOS .app bundle.")]
public partial class PublishMacOSBundleCommand : ICommand
{
private const string BundleName = "DiscordChatExporter.app";
private const string AppName = "DiscordChatExporter";
private const string AppCopyright = "© Oleksii Holub";
private const string AppIdentifier = "me.Tyrrrz.DiscordChatExporter";
private const string AppSpokenName = "Discord Chat Exporter";
private const string AppIconName = "AppIcon";
[CommandOption("publish-dir", Description = "Path to the publish output directory.")]
public required string PublishDirPath { get; set; }
[CommandOption("icons-file", Description = "Path to the .icns icons file.")]
public required string IconsFilePath { get; set; }
[CommandOption("full-version", Description = "Full version string (e.g. '1.2.3.4').")]
public required string FullVersion { get; set; }
[CommandOption("short-version", Description = "Short version string (e.g. '1.2.3').")]
public required string ShortVersion { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
// Set up paths
var publishDirPath = Path.GetFullPath(PublishDirPath);
var tempDirPath = Path.GetFullPath(
Path.Combine(publishDirPath, "../publish-macos-app-temp")
);
// Ensure the temporary directory is clean before use in case a previous run crashed
if (Directory.Exists(tempDirPath))
Directory.Delete(tempDirPath, true);
var bundleDirPath = Path.Combine(tempDirPath, BundleName);
var contentsDirPath = Path.Combine(bundleDirPath, "Contents");
try
{
// Copy icons into the .app's Resources folder
Directory.CreateDirectory(Path.Combine(contentsDirPath, "Resources"));
File.Copy(
IconsFilePath,
Path.Combine(contentsDirPath, "Resources", "AppIcon.icns"),
true
);
// Generate the Info.plist metadata file with the app information
// lang=xml
var plistContent = $"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>{AppName}</string>
<key>CFBundleName</key>
<string>{AppName}</string>
<key>CFBundleExecutable</key>
<string>{AppName}</string>
<key>NSHumanReadableCopyright</key>
<string>{AppCopyright}</string>
<key>CFBundleIdentifier</key>
<string>{AppIdentifier}</string>
<key>CFBundleSpokenName</key>
<string>{AppSpokenName}</string>
<key>CFBundleIconFile</key>
<string>{AppIconName}</string>
<key>CFBundleIconName</key>
<string>{AppIconName}</string>
<key>CFBundleVersion</key>
<string>{FullVersion}</string>
<key>CFBundleShortVersionString</key>
<string>{ShortVersion}</string>
<key>NSHighResolutionCapable</key>
<true />
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
""";
await File.WriteAllTextAsync(Path.Combine(contentsDirPath, "Info.plist"), plistContent);
// Delete the previous bundle if it exists
var existingBundlePath = Path.Combine(publishDirPath, BundleName);
if (Directory.Exists(existingBundlePath))
Directory.Delete(existingBundlePath, true);
// Move all files from the publish directory into the MacOS directory
Directory.CreateDirectory(Path.Combine(contentsDirPath, "MacOS"));
foreach (var entryPath in Directory.GetFileSystemEntries(publishDirPath))
{
var destinationPath = Path.Combine(
contentsDirPath,
"MacOS",
Path.GetFileName(entryPath)
);
if (Directory.Exists(entryPath))
Directory.Move(entryPath, destinationPath);
else
File.Move(entryPath, destinationPath);
}
// Move the final bundle into the publish directory for upload
Directory.Move(bundleDirPath, Path.Combine(publishDirPath, BundleName));
}
finally
{
// Clean up the temporary directory
if (Directory.Exists(tempDirPath))
Directory.Delete(tempDirPath, true);
}
}
}
@@ -1,6 +1,4 @@
using System;
using System.IO;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Cogwheel;
using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Core.Discord;
@@ -13,10 +11,7 @@ namespace DiscordChatExporter.Gui.Services;
[ObservableObject]
public partial class SettingsService()
: SettingsBase(
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
SerializerContext.Default
)
: SettingsBase(StartOptions.Current.SettingsPath, SerializerContext.Default)
{
[ObservableProperty]
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
@@ -9,24 +9,25 @@ namespace DiscordChatExporter.Gui.Services;
public class UpdateService(SettingsService settingsService) : IDisposable
{
private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()
? new UpdateManager(
new GithubPackageResolver(
"Tyrrrz",
"DiscordChatExporter",
// Examples:
// DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.win-x64.zip
// DiscordChatExporter.linux-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
),
new ZipPackageExtractor()
)
: null;
private readonly IUpdateManager? _updateManager =
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed
? new UpdateManager(
new GithubPackageResolver(
"Tyrrrz",
"DiscordChatExporter",
// Examples:
// DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.win-x64.zip
// DiscordChatExporter.linux-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
),
new ZipPackageExtractor()
)
: null;
private Version? _updateVersion;
private bool _updatePrepared;
private bool _updaterLaunched;
private bool _isUpdatePrepared;
private bool _isUpdaterLaunched;
public async ValueTask<Version?> CheckForUpdatesAsync()
{
@@ -51,7 +52,7 @@ public class UpdateService(SettingsService settingsService) : IDisposable
try
{
await _updateManager.PrepareUpdateAsync(_updateVersion = version);
_updatePrepared = true;
_isUpdatePrepared = true;
}
catch (UpdaterAlreadyLaunchedException)
{
@@ -71,13 +72,13 @@ public class UpdateService(SettingsService settingsService) : IDisposable
if (!settingsService.IsAutoUpdateEnabled)
return;
if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
if (_updateVersion is null || !_isUpdatePrepared || _isUpdaterLaunched)
return;
try
{
_updateManager.LaunchUpdater(_updateVersion, needRestart);
_updaterLaunched = true;
_isUpdaterLaunched = true;
}
catch (UpdaterAlreadyLaunchedException)
{
+31
View File
@@ -0,0 +1,31 @@
using System;
using System.IO;
namespace DiscordChatExporter.Gui;
public partial class StartOptions
{
public required string SettingsPath { get; init; }
public required bool IsAutoUpdateAllowed { get; init; }
}
public partial class StartOptions
{
public static StartOptions Current { get; } =
new()
{
SettingsPath =
Environment.GetEnvironmentVariable("DISCORDCHATEXPORTER_SETTINGS_PATH") is { } path
&& !string.IsNullOrWhiteSpace(path)
? Path.EndsInDirectorySeparator(path) || Directory.Exists(path)
? Path.Combine(path, "Settings.dat")
: path
: Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
IsAutoUpdateAllowed = !(
Environment.GetEnvironmentVariable("DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE")
is { } env
&& env.Equals("false", StringComparison.OrdinalIgnoreCase)
),
};
}
@@ -0,0 +1,15 @@
using System.Windows.Input;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class CommandExtensions
{
extension(ICommand command)
{
public void ExecuteIfCan(object? parameter = null)
{
if (command.CanExecute(parameter))
command.Execute(parameter);
}
}
}
@@ -12,7 +12,7 @@ internal static class NotifyPropertyChangedExtensions
{
public IDisposable WatchProperty<TProperty>(
Expression<Func<TOwner, TProperty>> propertyExpression,
Action callback,
Action<TProperty> callback,
bool watchInitialValue = false
)
{
@@ -20,6 +20,8 @@ internal static class NotifyPropertyChangedExtensions
if (memberExpression?.Member is not PropertyInfo property)
throw new ArgumentException("Provided expression must reference a property.");
var getValue = propertyExpression.Compile();
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (
@@ -27,14 +29,14 @@ internal static class NotifyPropertyChangedExtensions
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
)
{
callback();
callback(getValue(owner));
}
}
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValue)
callback();
callback(getValue(owner));
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -54,14 +53,14 @@ public partial class DashboardViewModel : ViewModelBase
_eventRoot.Add(
Progress.WatchProperty(
o => o.Current,
() => OnPropertyChanged(nameof(IsProgressIndeterminate))
_ => OnPropertyChanged(nameof(IsProgressIndeterminate))
)
);
_eventRoot.Add(
SelectedChannels.WatchProperty(
o => o.Count,
() => ExportCommand.NotifyCanExecuteChanged()
_ => ExportCommand.NotifyCanExecuteChanged()
)
);
}
@@ -96,16 +95,17 @@ public partial class DashboardViewModel : ViewModelBase
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
[RelayCommand]
private void Initialize()
public override Task InitializeAsync()
{
if (!string.IsNullOrWhiteSpace(_settingsService.LastToken))
Token = _settingsService.LastToken;
return Task.CompletedTask;
}
[RelayCommand]
private async Task ShowSettingsAsync() =>
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
await _dialogManager.ShowDialogAsync(_viewModelManager.GetSettingsViewModel());
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
@@ -142,7 +142,7 @@ public partial class DashboardViewModel : ViewModelBase
}
catch (Exception ex)
{
var dialog = _viewModelManager.CreateMessageBoxViewModel(
var dialog = _viewModelManager.GetMessageBoxViewModel(
LocalizationManager.ErrorPullingGuildsTitle,
ex.ToString()
);
@@ -209,7 +209,7 @@ public partial class DashboardViewModel : ViewModelBase
}
catch (Exception ex)
{
var dialog = _viewModelManager.CreateMessageBoxViewModel(
var dialog = _viewModelManager.GetMessageBoxViewModel(
LocalizationManager.ErrorPullingChannelsTitle,
ex.ToString()
);
@@ -236,7 +236,7 @@ public partial class DashboardViewModel : ViewModelBase
if (_discord is null || SelectedGuild is null || !SelectedChannels.Any())
return;
var dialog = _viewModelManager.CreateExportSetupViewModel(
var dialog = _viewModelManager.GetExportSetupViewModel(
SelectedGuild,
SelectedChannels.Select(c => c.Channel).ToArray()
);
@@ -315,7 +315,7 @@ public partial class DashboardViewModel : ViewModelBase
}
catch (Exception ex)
{
var dialog = _viewModelManager.CreateMessageBoxViewModel(
var dialog = _viewModelManager.GetMessageBoxViewModel(
LocalizationManager.ErrorExportingTitle,
ex.ToString()
);
@@ -102,8 +102,7 @@ public partial class ExportSetupViewModel(
? MessageFilter.Parse(MessageFilterValue)
: MessageFilter.Null;
[RelayCommand]
private void Initialize()
public override Task InitializeAsync()
{
// Persist preferences
SelectedFormat = settingsService.LastExportFormat;
@@ -126,6 +125,8 @@ public partial class ExportSetupViewModel(
|| ShouldReuseAssets
|| !string.IsNullOrWhiteSpace(AssetsDirPath)
|| IsReverseMessageOrder;
return Task.CompletedTask;
}
[RelayCommand]
@@ -46,6 +46,9 @@ public class SettingsViewModel : DialogViewModelBase
set => _settingsService.Language = value;
}
public bool IsAutoUpdateAvailable { get; } =
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed;
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;
@@ -2,7 +2,6 @@
using System.Diagnostics;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Services;
@@ -22,14 +21,14 @@ public partial class MainViewModel(
{
public string Title { get; } = $"{Program.Name} v{Program.VersionString}";
public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel();
public DashboardViewModel Dashboard { get; } = viewModelManager.GetDashboardViewModel();
private async Task ShowUkraineSupportMessageAsync()
{
if (!settingsService.IsUkraineSupportMessageEnabled)
return;
var dialog = viewModelManager.CreateMessageBoxViewModel(
var dialog = viewModelManager.GetMessageBoxViewModel(
localizationManager.UkraineSupportTitle,
localizationManager.UkraineSupportMessage,
localizationManager.LearnMoreButton,
@@ -53,7 +52,7 @@ public partial class MainViewModel(
if (Debugger.IsAttached)
return;
var dialog = viewModelManager.CreateMessageBoxViewModel(
var dialog = viewModelManager.GetMessageBoxViewModel(
localizationManager.UnstableBuildTitle,
string.Format(localizationManager.UnstableBuildMessage, Program.Name),
localizationManager.SeeReleasesButton,
@@ -100,8 +99,7 @@ public partial class MainViewModel(
}
}
[RelayCommand]
private async Task InitializeAsync()
public override async Task InitializeAsync()
{
await ShowUkraineSupportMessageAsync();
await ShowDevelopmentBuildMessageAsync();
@@ -4,6 +4,7 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels.Components;
namespace DiscordChatExporter.Gui.Views.Components;
@@ -12,16 +13,13 @@ public partial class DashboardView : UserControl<DashboardViewModel>
{
public DashboardView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args)
{
DataContext.InitializeCommand.Execute(null);
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>
TokenValueTextBox.Focus();
}
private void AvailableGuildsListBox_OnSelectionChanged(
object? sender,
SelectionChangedEventArgs args
) => DataContext.PullChannelsCommand.Execute(null);
) => DataContext.PullChannelsCommand.ExecuteIfCan(null);
private void AvailableChannelsTreeView_OnSelectionChanged(
object? sender,
@@ -43,6 +41,6 @@ public partial class DashboardView : UserControl<DashboardViewModel>
if (DataContext.SelectedChannels.Count != 1)
return;
DataContext.ExportCommand.Execute(null);
DataContext.ExportCommand.ExecuteIfCan(null);
}
}
@@ -54,8 +54,7 @@ public partial class HyperLink : UserControl
{
if (Command is not null)
{
if (Command.CanExecute(CommandParameter))
Command.Execute(CommandParameter);
Command.ExecuteIfCan(CommandParameter);
}
else if (!string.IsNullOrWhiteSpace(Url))
{
@@ -10,8 +10,7 @@
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
x:Name="UserControl"
Width="380"
x:DataType="dialogs:ExportSetupViewModel"
Loaded="UserControl_OnLoaded">
x:DataType="dialogs:ExportSetupViewModel">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Guild/channel info -->
<Grid
@@ -292,4 +291,4 @@
Theme="{DynamicResource MaterialOutlineButton}" />
</Grid>
</Grid>
</UserControl>
</UserControl>
@@ -1,4 +1,3 @@
using Avalonia.Interactivity;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
@@ -7,7 +6,4 @@ namespace DiscordChatExporter.Gui.Views.Dialogs;
public partial class ExportSetupView : UserControl<ExportSetupViewModel>
{
public ExportSetupView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>
DataContext.InitializeCommand.Execute(null);
}
@@ -33,30 +33,40 @@
<UniformGrid
Grid.Row="2"
Margin="16"
HorizontalAlignment="Right"
Margin="8"
Columns="{Binding ButtonsCount}">
<!-- OK -->
<Button
Margin="8"
HorizontalContentAlignment="Stretch"
Command="{Binding CloseCommand}"
Content="{Binding DefaultButtonText}"
IsDefault="True"
IsVisible="{Binding IsDefaultButtonVisible}"
Theme="{DynamicResource MaterialOutlineButton}">
Theme="{DynamicResource MaterialOutlineButton}"
ToolTip.Tip="{Binding DefaultButtonText}">
<Button.CommandParameter>
<system:Boolean>True</system:Boolean>
</Button.CommandParameter>
<TextBlock
Text="{Binding DefaultButtonText}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Button>
<!-- Cancel -->
<Button
Margin="16,0,0,0"
HorizontalAlignment="Stretch"
Margin="8"
HorizontalContentAlignment="Stretch"
Command="{Binding CloseCommand}"
Content="{Binding CancelButtonText}"
IsCancel="True"
IsVisible="{Binding IsCancelButtonVisible}"
Theme="{DynamicResource MaterialOutlineButton}" />
Theme="{DynamicResource MaterialOutlineButton}"
ToolTip.Tip="{Binding CancelButtonText}">
<TextBlock
Text="{Binding CancelButtonText}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Button>
</UniformGrid>
</Grid>
</UserControl>
@@ -50,8 +50,7 @@
<!-- Auto-updates -->
<DockPanel
Margin="16,8"
IsVisible="{OnPlatform False,
Windows=True}"
IsVisible="{Binding IsAutoUpdateAvailable}"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
+2 -5
View File
@@ -14,12 +14,9 @@
Icon="/favicon.ico"
RenderOptions.BitmapInterpolationMode="HighQuality"
WindowStartupLocation="CenterScreen">
<dialogHostAvalonia:DialogHost
x:Name="DialogHost"
CloseOnClickAway="False"
Loaded="DialogHost_OnLoaded">
<dialogHostAvalonia:DialogHost x:Name="DialogHost" CloseOnClickAway="False">
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
<ContentControl Content="{Binding Dashboard}" />
</materialStyles:SnackbarHost>
</dialogHostAvalonia:DialogHost>
</Window>
</Window>
@@ -1,5 +1,4 @@
using Avalonia.Interactivity;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.ViewModels;
namespace DiscordChatExporter.Gui.Views;
@@ -7,7 +6,4 @@ namespace DiscordChatExporter.Gui.Views;
public partial class MainView : Window<MainViewModel>
{
public MainView() => InitializeComponent();
private void DialogHost_OnLoaded(object? sender, RoutedEventArgs args) =>
DataContext.InitializeCommand.Execute(null);
}
+14 -10
View File
@@ -1,9 +1,9 @@
# DiscordChatExporter
[![Status](https://img.shields.io/badge/status-maintenance-ffd700.svg)](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
[![Status](https://img.shields.io/badge/status-maintenance-ffd700.svg)](https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md)
[![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://tyrrrz.me/ukraine)
[![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/DiscordChatExporter/main.yml?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
[![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/DiscordChatExporter/main.yml?branch=prime)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/prime)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Pulls](https://img.shields.io/docker/pulls/tyrrrz/discordchatexporter)](https://hub.docker.com/r/tyrrrz/discordchatexporter)
@@ -27,7 +27,7 @@ It works with direct messages, group messages, and server channels, and supports
> 💬 If you want to chat, **join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/why-so-political.md)</sup>
## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/prime/docs/why-so-political.md)</sup>
By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements:
@@ -43,23 +43,27 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
- **Graphical user interface** (desktop app):
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.*.zip`
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.*.zip`
- 📦 [Scoop](https://scoop.sh/#/apps?q=DiscordChatExporter&p=1&id=c71b7367623c560a2dc746b9739b9568b79b59ae): `scoop install extras/discordchatexporter` (community-maintained)
- 📦 [WinGet](https://winstall.app/apps/Tyrrrz.DiscordChatExporter.GUI): `winget install Tyrrrz.DiscordChatExporter.GUI` (community-maintained)
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-gui): `yay -S discord-chat-exporter-gui` (community-maintained)
- 📦 [Nix](https://search.nixos.org/packages?show=discordchatexporter-desktop): `nix-shell -p discordchatexporter-desktop` (community-maintained)
- **Command-line interface** (terminal app):
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.Cli.*.zip`
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.Cli.*.zip`
- 🐋 [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
- 📦 [WinGet](https://winstall.app/apps/Tyrrrz.DiscordChatExporter.CLI): `winget install Tyrrrz.DiscordChatExporter.CLI` (community-maintained)
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `yay -S discord-chat-exporter-cli` (community-maintained)
- 📦 [Nix](https://search.nixos.org/packages?show=discordchatexporter-cli): `nix-shell -p discordchatexporter-cli` (community-maintained)
> [!IMPORTANT]
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
> To launch the GUI version of the app on MacOS, you may need to first remove the downloaded file from quarantine.
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
> [!NOTE]
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
> Community-maintained packages are published independently from this repository and may not always be up to date with the latest release.
> [!NOTE]
> AUR and Nix packages linked above are maintained by the community.
> If you have any issues with them, please contact the corresponding maintainers.
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
## Features
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 36 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 26 KiB