Full Code of Tyrrrz/DiscordChatExporter for AI

prime 9afecd47bd32 cached
252 files
1.0 MB
306.1k tokens
644 symbols
1 requests
Download .txt
Showing preview only (1,129K chars total). Download the full file or copy to clipboard to get everything.
Repository: Tyrrrz/DiscordChatExporter
Branch: prime
Commit: 9afecd47bd32
Files: 252
Total size: 1.0 MB

Directory structure:
gitextract_vxgw9uzg/

├── .docs/
│   ├── Docker.md
│   ├── Getting-started.md
│   ├── Message-filters.md
│   ├── Readme.md
│   ├── Scheduling-Linux.md
│   ├── Scheduling-MacOS.md
│   ├── Scheduling-Windows.md
│   ├── Token-and-IDs.md
│   ├── Troubleshooting.md
│   ├── Using-the-CLI.md
│   └── Using-the-GUI.md
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.yml
│   │   └── config.yml
│   ├── dependabot.yml
│   └── workflows/
│       ├── docker.yml
│       └── main.yml
├── .gitignore
├── Directory.Build.props
├── Directory.Packages.props
├── DiscordChatExporter.Cli/
│   ├── Commands/
│   │   ├── Base/
│   │   │   ├── DiscordCommandBase.cs
│   │   │   └── ExportCommandBase.cs
│   │   ├── Converters/
│   │   │   ├── ThreadInclusionModeBindingConverter.cs
│   │   │   └── TruthyBooleanBindingConverter.cs
│   │   ├── ExportAllCommand.cs
│   │   ├── ExportChannelsCommand.cs
│   │   ├── ExportDirectMessagesCommand.cs
│   │   ├── ExportGuildCommand.cs
│   │   ├── GetChannelsCommand.cs
│   │   ├── GetDirectChannelsCommand.cs
│   │   ├── GetGuildsCommand.cs
│   │   ├── GuideCommand.cs
│   │   └── Shared/
│   │       └── ThreadInclusionMode.cs
│   ├── DiscordChatExporter.Cli.csproj
│   ├── Program.cs
│   └── Utils/
│       └── Extensions/
│           └── ConsoleExtensions.cs
├── DiscordChatExporter.Cli.Tests/
│   ├── DiscordChatExporter.Cli.Tests.csproj
│   ├── Infra/
│   │   ├── ChannelIds.cs
│   │   ├── ExportWrapper.cs
│   │   └── Secrets.cs
│   ├── Readme.md
│   ├── Specs/
│   │   ├── CsvContentSpecs.cs
│   │   ├── DateRangeSpecs.cs
│   │   ├── FilterSpecs.cs
│   │   ├── HtmlAttachmentSpecs.cs
│   │   ├── HtmlContentSpecs.cs
│   │   ├── HtmlEmbedSpecs.cs
│   │   ├── HtmlForwardSpecs.cs
│   │   ├── HtmlGroupingSpecs.cs
│   │   ├── HtmlMarkdownSpecs.cs
│   │   ├── HtmlMentionSpecs.cs
│   │   ├── HtmlReplySpecs.cs
│   │   ├── HtmlStickerSpecs.cs
│   │   ├── JsonAttachmentSpecs.cs
│   │   ├── JsonContentSpecs.cs
│   │   ├── JsonEmbedSpecs.cs
│   │   ├── JsonEmojiSpecs.cs
│   │   ├── JsonForwardSpecs.cs
│   │   ├── JsonMentionSpecs.cs
│   │   ├── JsonStickerSpecs.cs
│   │   ├── PartitioningSpecs.cs
│   │   ├── PlainTextContentSpecs.cs
│   │   ├── PlainTextForwardSpecs.cs
│   │   └── SelfContainedSpecs.cs
│   ├── Utils/
│   │   ├── Extensions/
│   │   │   └── StringExtensions.cs
│   │   ├── Html.cs
│   │   ├── TempDir.cs
│   │   └── TempFile.cs
│   └── xunit.runner.json
├── DiscordChatExporter.Cli.dockerfile
├── DiscordChatExporter.Core/
│   ├── Discord/
│   │   ├── Data/
│   │   │   ├── Application.cs
│   │   │   ├── ApplicationFlags.cs
│   │   │   ├── Attachment.cs
│   │   │   ├── Channel.cs
│   │   │   ├── ChannelConnection.cs
│   │   │   ├── ChannelKind.cs
│   │   │   ├── Common/
│   │   │   │   ├── FileSize.cs
│   │   │   │   ├── IHasId.cs
│   │   │   │   └── ImageCdn.cs
│   │   │   ├── Embeds/
│   │   │   │   ├── Embed.cs
│   │   │   │   ├── EmbedAuthor.cs
│   │   │   │   ├── EmbedField.cs
│   │   │   │   ├── EmbedFooter.cs
│   │   │   │   ├── EmbedImage.cs
│   │   │   │   ├── EmbedKind.cs
│   │   │   │   ├── EmbedVideo.cs
│   │   │   │   ├── SpotifyTrackEmbedProjection.cs
│   │   │   │   ├── TwitchClipEmbedProjection.cs
│   │   │   │   └── YouTubeVideoEmbedProjection.cs
│   │   │   ├── Emoji.cs
│   │   │   ├── EmojiIndex.cs
│   │   │   ├── Guild.cs
│   │   │   ├── Interaction.cs
│   │   │   ├── Invite.cs
│   │   │   ├── Member.cs
│   │   │   ├── Message.cs
│   │   │   ├── MessageFlags.cs
│   │   │   ├── MessageKind.cs
│   │   │   ├── MessageReference.cs
│   │   │   ├── MessageReferenceKind.cs
│   │   │   ├── MessageSnapshot.cs
│   │   │   ├── Reaction.cs
│   │   │   ├── Role.cs
│   │   │   ├── Sticker.cs
│   │   │   ├── StickerFormat.cs
│   │   │   └── User.cs
│   │   ├── DiscordClient.cs
│   │   ├── Dump/
│   │   │   ├── DataDump.cs
│   │   │   └── DataDumpChannel.cs
│   │   ├── RateLimitPreference.cs
│   │   ├── Snowflake.cs
│   │   └── TokenKind.cs
│   ├── DiscordChatExporter.Core.csproj
│   ├── Exceptions/
│   │   ├── ChannelEmptyException.cs
│   │   └── DiscordChatExporterException.cs
│   ├── Exporting/
│   │   ├── ChannelExporter.cs
│   │   ├── CsvMessageWriter.cs
│   │   ├── ExportAssetDownloader.cs
│   │   ├── ExportContext.cs
│   │   ├── ExportFormat.cs
│   │   ├── ExportRequest.cs
│   │   ├── Filtering/
│   │   │   ├── BinaryExpressionKind.cs
│   │   │   ├── BinaryExpressionMessageFilter.cs
│   │   │   ├── ContainsMessageFilter.cs
│   │   │   ├── FromMessageFilter.cs
│   │   │   ├── HasMessageFilter.cs
│   │   │   ├── MentionsMessageFilter.cs
│   │   │   ├── MessageContentMatchKind.cs
│   │   │   ├── MessageFilter.cs
│   │   │   ├── NegatedMessageFilter.cs
│   │   │   ├── NullMessageFilter.cs
│   │   │   ├── Parsing/
│   │   │   │   └── FilterGrammar.cs
│   │   │   └── ReactionMessageFilter.cs
│   │   ├── HtmlMarkdownVisitor.cs
│   │   ├── HtmlMessageExtensions.cs
│   │   ├── HtmlMessageWriter.cs
│   │   ├── JsonMessageWriter.cs
│   │   ├── MessageExporter.cs
│   │   ├── MessageGroupTemplate.cshtml
│   │   ├── MessageWriter.cs
│   │   ├── Partitioning/
│   │   │   ├── FileSizePartitionLimit.cs
│   │   │   ├── MessageCountPartitionLimit.cs
│   │   │   ├── NullPartitionLimit.cs
│   │   │   └── PartitionLimit.cs
│   │   ├── PlainTextMarkdownVisitor.cs
│   │   ├── PlainTextMessageExtensions.cs
│   │   ├── PlainTextMessageWriter.cs
│   │   ├── PostambleTemplate.cshtml
│   │   └── PreambleTemplate.cshtml
│   ├── Markdown/
│   │   ├── EmojiNode.cs
│   │   ├── FormattingKind.cs
│   │   ├── FormattingNode.cs
│   │   ├── HeadingNode.cs
│   │   ├── IContainerNode.cs
│   │   ├── InlineCodeBlockNode.cs
│   │   ├── LinkNode.cs
│   │   ├── ListItemNode.cs
│   │   ├── ListNode.cs
│   │   ├── MarkdownNode.cs
│   │   ├── MentionKind.cs
│   │   ├── MentionNode.cs
│   │   ├── MultiLineCodeBlockNode.cs
│   │   ├── Parsing/
│   │   │   ├── AggregateMatcher.cs
│   │   │   ├── IMatcher.cs
│   │   │   ├── MarkdownContext.cs
│   │   │   ├── MarkdownParser.cs
│   │   │   ├── MarkdownVisitor.cs
│   │   │   ├── ParsedMatch.cs
│   │   │   ├── RegexMatcher.cs
│   │   │   ├── StringMatcher.cs
│   │   │   └── StringSegment.cs
│   │   ├── TextNode.cs
│   │   └── TimestampNode.cs
│   └── Utils/
│       ├── Docker.cs
│       ├── Extensions/
│       │   ├── AsyncCollectionExtensions.cs
│       │   ├── CollectionExtensions.cs
│       │   ├── ColorExtensions.cs
│       │   ├── ExceptionExtensions.cs
│       │   ├── GenericExtensions.cs
│       │   ├── HttpExtensions.cs
│       │   ├── PathExtensions.cs
│       │   ├── StringExtensions.cs
│       │   ├── SuperpowerExtensions.cs
│       │   └── TimeSpanExtensions.cs
│       ├── Http.cs
│       ├── Url.cs
│       └── UrlBuilder.cs
├── DiscordChatExporter.Gui/
│   ├── App.axaml
│   ├── App.axaml.cs
│   ├── Converters/
│   │   ├── ChannelToHierarchicalNameStringConverter.cs
│   │   ├── ExportFormatToStringConverter.cs
│   │   ├── LocaleToDisplayNameStringConverter.cs
│   │   ├── MarkdownToInlinesConverter.cs
│   │   ├── RateLimitPreferenceToStringConverter.cs
│   │   └── SnowflakeToTimestampStringConverter.cs
│   ├── DiscordChatExporter.Gui.csproj
│   ├── Framework/
│   │   ├── DialogManager.cs
│   │   ├── DialogViewModelBase.cs
│   │   ├── SnackbarManager.cs
│   │   ├── ThemeVariant.cs
│   │   ├── UserControl.cs
│   │   ├── ViewManager.cs
│   │   ├── ViewModelBase.cs
│   │   ├── ViewModelManager.cs
│   │   └── Window.cs
│   ├── Localization/
│   │   ├── Language.cs
│   │   ├── LocalizationManager.English.cs
│   │   ├── LocalizationManager.French.cs
│   │   ├── LocalizationManager.German.cs
│   │   ├── LocalizationManager.Spanish.cs
│   │   ├── LocalizationManager.Ukrainian.cs
│   │   └── LocalizationManager.cs
│   ├── Models/
│   │   └── ThreadInclusionMode.cs
│   ├── Program.cs
│   ├── Publish-MacOSBundle.ps1
│   ├── Services/
│   │   ├── SettingsService.TokenEncryptionConverter.cs
│   │   ├── SettingsService.cs
│   │   └── UpdateService.cs
│   ├── StartOptions.cs
│   ├── Utils/
│   │   ├── Disposable.cs
│   │   ├── DisposableCollector.cs
│   │   ├── Extensions/
│   │   │   ├── AvaloniaExtensions.cs
│   │   │   ├── DisposableExtensions.cs
│   │   │   ├── EnvironmentExtensions.cs
│   │   │   ├── MarkdigExtensions.cs
│   │   │   ├── NotifyPropertyChangedExtensions.cs
│   │   │   └── ProcessExtensions.cs
│   │   ├── Internationalization.cs
│   │   └── NativeMethods.cs
│   ├── ViewModels/
│   │   ├── Components/
│   │   │   └── DashboardViewModel.cs
│   │   ├── Dialogs/
│   │   │   ├── ExportSetupViewModel.cs
│   │   │   ├── MessageBoxViewModel.cs
│   │   │   └── SettingsViewModel.cs
│   │   └── MainViewModel.cs
│   └── Views/
│       ├── Components/
│       │   ├── DashboardView.axaml
│       │   └── DashboardView.axaml.cs
│       ├── Controls/
│       │   ├── HyperLink.axaml
│       │   └── HyperLink.axaml.cs
│       ├── Dialogs/
│       │   ├── ExportSetupView.axaml
│       │   ├── ExportSetupView.axaml.cs
│       │   ├── MessageBoxView.axaml
│       │   ├── MessageBoxView.axaml.cs
│       │   ├── SettingsView.axaml
│       │   └── SettingsView.axaml.cs
│       ├── MainView.axaml
│       └── MainView.axaml.cs
├── DiscordChatExporter.sln
├── License.txt
├── NuGet.config
├── Readme.md
├── docker-entrypoint.sh
├── favicon.icns
└── global.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .docs/Docker.md
================================================
# Docker usage instructions

Docker distribution of DiscordChatExporter provides a way to run the app in a virtualized and isolated environment. Due to the nature of Docker, you also don't need to install any prerequisites otherwise required by DCE.

> **Note**:
> Only the CLI flavor of DiscordChatExporter is available for use with Docker.

## Pulling

This will download the [Docker image from the registry](https://hub.docker.com/r/tyrrrz/discordchatexporter) to your computer. You can run this command again to update when a new version is released.

```console
$ docker pull tyrrrz/discordchatexporter:stable
```

Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:

- `stable` — latest stable version release. This tag is updated with each release of a new project version. Recommended for personal use.
- `x.y.z` (e.g. `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
- `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).

## Usage

To run the CLI in Docker and render help text:

```console
$ docker run --rm tyrrrz/discordchatexporter:stable
```

To export a channel:

```console
$ docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
```

If you want colored output and real-time progress reporting, pass the `-it` (interactive + pseudo-terminal) option:

```console
$ docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
```

The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` directory inside the container to a path on your host machine. Replace `/path/on/machine` with the directory you want the files to be saved at.

> **Note**:
> If you are running SELinux, you will need to add the `:z` option after `/out`, e.g.:
>
> ```console
> $ docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
> ```
>
> For more information, refer to the [Docker docs SELinux labels for bind mounts page](https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label).

You can also use the current working directory as the output directory by specifying:

- `-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/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).

## Unix permissions issues

This image was designed with a user running as uid:gid of 1000:1000.

If your current user has different IDs, and you want to generate files directly editable for your user, you might want to run the container like this:

```console
$ mkdir data # or chown -R $(id -u):$(id -g) data
$ docker run -it --rm -v $PWD/data:/out --user $(id -u):$(id -g) tyrrrz/discordchatexporter:stable export -t TOKEN -g CHANNELID
```

## Environment variables

DiscordChatExpoter CLI accepts the `DISCORD_TOKEN` environment variable as a fallback for the `--token` option. You can set this variable either with the `--env` Docker option or with a combination of the `--env-file` Docker option and a `.env` file.

Please refer to the [Docker documentation](https://docs.docker.com/engine/reference/commandline/run/#set-environment-variables--e---env---env-file) for more information.


================================================
FILE: .docs/Getting-started.md
================================================
# Getting started

Welcome to the getting started page!
Here you'll learn how to use every **DiscordChatExporter** (DCE for short) feature.
For other things you can do with DCE, check out the [Guides](Readme.md#guides) section.

If you still have unanswered questions after reading this page or if you have encountered a problem, please visit our [FAQ & Troubleshooting](Troubleshooting.md) section.

The information presented on this page is valid for **all** platforms.

## GUI or CLI?

![GUI vs CLI](https://i.imgur.com/j9OTxRB.png)

**DCE** has two different versions:

- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use.
  You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.*.zip` file.
- **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges.
  You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.Cli.*.zip` file.

There are dedicated guides for each version:

- [Using the GUI](Using-the-GUI.md)
- [Using the CLI](Using-the-CLI.md)

## File formats

### HTML

![](https://i.imgur.com/S7lBTkV.png)
The HTML format replicates Discord's interface, making it the most user-friendly option.
It's the best format for attachment preview and sharing.
You can open `.html` files with a web browser, such as Google Chrome.

> [!WARNING]
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
> Export using the "Download assets" (`--media`) option to avoid this.

### Plain Text

<img src="https://i.imgur.com/PbUyRXD.png" height="400"/>

The Plain Text format formats messages as plain text, and has the smallest size.
You can open `.txt` files with a text editor, such as Notepad.

### JSON

<img src="https://i.imgur.com/FAeSA4O.png" height="400"/>

The JSON format contains more technical information and is easily parsable.
You can open `.json` files with a text editor, such as Notepad.

### CSV

![](https://i.imgur.com/VEVUsKs.png)
![](https://i.imgur.com/1vPmQqQ.png)

The CSV format allows for easy parsing of the chat log. Depending on your needs, the JSON format might be better.
You can open `.csv` files with a text editor, such as Notepad, or a spreadsheet app, like Microsoft Excel and Google Sheets.


================================================
FILE: .docs/Message-filters.md
================================================
# Message filters

You can use a special notation to filter messages that you want to have included in an export. The notation syntax is designed to mimic Discord's search query syntax, but with additional capabilities.

To configure a filter, specify it in advanced export parameters when using the GUI or by passing the `--filter` option when using the CLI. For the CLI version, see also [caveats](#cli-caveats).

## Examples

- Filter by user

```console
from:Tyrrrz
```

- Filter by user (with discriminator)

```console
from:Tyrrrz#1234
```

- Filter by message content (allowed values: `link`, `embed`, `file`, `video`, `image`, `sound`)

```console
has:image
```

- Filter by mentioned user (same rules apply as with `from:` filter)

```console
mentions:Tyrrrz#1234
```

- Filter by contained text (has word "hello" and word "world" somewhere in the message text):

```console
hello world
```

- Filter by contained text (has the string "hello world" somewhere in the message text):

```console
"hello world"
```

- Combine multiple filters ('and'):

```console
from:Tyrrrz has:image
```

- Same thing but with an explicit operator:

```console
from:Tyrrrz & has:image
```

- Combine multiple filters ('or'):

```console
from:Tyrrrz | from:"96-LB"
```

- Combine multiple filters using groups:

```console
(from:Tyrrrz | from:"96-LB") has:image
```

- Negate a filter:

```console
-from:Tyrrrz | -has:image
```

- Negate a grouped filter:

```console
-(from:Tyrrrz has:image)
```

- Escape special characters (`-` is escaped below, so it's not parsed as negation operator):

```console
from:96\-LB
```

## CLI Caveats

In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:

```console
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
```

If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:

```console
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
```

Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):

```console
$ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
```


================================================
FILE: .docs/Readme.md
================================================
# Home

## Installation & Usage

- Getting started:
  - [Using the GUI](Using-the-GUI.md)
  - [Using the CLI](Using-the-CLI.md)
  - [File formats](Getting-started.md#file-formats)

## Guides

- [How to get Token and Channel IDs](Token-and-IDs.md)
- [How to use message filters](Message-filters.md)
- Export scheduling with CLI:
  - [Windows](Scheduling-Windows.md)
  - [macOS](Scheduling-MacOS.md)
  - [Linux](Scheduling-Linux.md)

## Video tutorial

- Video by [NoIntro Tutorials](https://youtube.com/channel/UCFezKSxdNKJe77-hYiuXu3Q) (using DiscordChatExporter GUI)

[![Video tutorial](https://i.ytimg.com/vi/jjtu0VQXV7I/hqdefault.jpg)](https://youtube.com/watch?v=jjtu0VQXV7I)

## FAQ & Troubleshooting

- [General questions](Troubleshooting.md#general)
- [First steps help](Troubleshooting.md#first-steps)
- [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing)
- [Errors](Troubleshooting.md#errors)
- [**More help**](Troubleshooting.md)


================================================
FILE: .docs/Scheduling-Linux.md
================================================
# Scheduling exports with Cron

## Creating the script

1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh`

> **Note**:
> You can't use your mouse in nano, use the arrow keys to control the cursor (caret).

2. Paste the following into the text file:

```bash
#!/bin/bash
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs

TOKEN=tokenhere
CHANNELID=channelhere
DLLFOLDER=dceFOLDERpathhere
FILENAME=filenamehere
EXPORTDIRECTORY=dirhere
EXPORTFORMAT=formathere
# Available export formats: plaintext, htmldark, htmllight, json, csv
# /\ CaSe-SeNsItIvE /\
# You can edit the export command on line 40 if you'd like to include more options like date ranges and date format. You can't use partitioning (-p) with this script.

# This will verify if EXPORTFORMAT is valid and will set the final file extension according to it. If the format is invalid, the script will display a message and exit.
if [[ "$EXPORTFORMAT" == "plaintext" ]]; then
FORMATEXT=.txt
elif [[ "$EXPORTFORMAT" == "htmldark" ]] || [[ "$EXPORTFORMAT" == "htmllight" ]]; then
FORMATEXT=.html
elif [[ "$EXPORTFORMAT" == "json" ]]; then
FORMATEXT=.json
elif [[ "$EXPORTFORMAT" == "csv" ]]; then
FORMATEXT=.csv
else
echo "$EXPORTFORMAT - Unknown export format"
echo "Available export formats: plaintext, htmldark, htmllight, csv, json"
echo "/\ CaSe-SeNsItIvE /\\"
exit 1
fi

# This will change the script's directory to DLLPATH, if unable to do so, the script will exit.
cd $DLLFOLDER || exit 1

# This will export your chat
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp

# This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")

# This will move the .tmp file to the desired export location, if unable to do so, it will attempt to delete the .tmp file.
if ! mv "$FILENAME.tmp" "${EXPORTDIRECTORY//\"}/$FILENAME-$CURRENTTIME$FORMATEXT" ; then
echo "Unable to move $FILENAME.tmp to $EXPORTDIRECTORY/$FILENAME-$CURRENTTIME$FORMATEXT."
echo "Cleaning up..."
  if ! rm -Rf "$FILENAME.tmp" ; then
  echo "Unable to remove $FILENAME.tmp."
  fi
exit 1
fi
exit 0
```

3. Replace:

- `tokenhere` with your [Token](Token-and-IDs.md).
- `channelhere` with a [Channel ID](Token-and-IDs.md).
- `dceFOLDERpathhere` with DCE's **directory path** (e.g. `/path/to/folder`, NOT `/path/to/folder/DiscordChatExporter.dll`).
- `filenamehere` with the exported channel's filename, without spaces.
- `dirhere` with the export directory (e.g. /home/user/Documents/Discord\ Exports).
- `formathere` with one of the available export formats.

> **Note**:
> Remember to escape spaces (add `\` before them) or to quote (") the paths (`"/home/my user"`)!

> **Note**:
> To save, hold down CTRL and then press O, if asked for a filename, type it and press ENTER. Hit CTRL+X to exit the text editor.
> [Check out this page](https://wiki.gentoo.org/wiki/Nano/Basics_Guide) if you want to know more about nano.

4. Make your script executable with `chmod +x /path/to/DiscordChatExporter/cron.sh`

5. Let's edit the cron file. If you want to run the script with your user privileges, edit it by running `crontab -e`. If you want to run the script as root, edit it with `sudo crontab -e`. If this is your first time running this command, you might be asked to select a text editor. Nano is easier for beginners.

6. Add the following to the end of the file `* * * * * /path/to/DiscordChatExporter/cron.sh >/tmp/discordchatexporter.log 2>/tmp/discordchatexportererror.log`. Don't forget to replace the `/path/to/DiscordChatExporter/cron.sh`!

> **Note**:
> If you don't want logs to be created, replace both `/tmp/discordchatexporter.log` with `/dev/null`.

Then replace the \*s according to:

![](https://i.imgur.com/RY7USM6.png)

---

**Examples**:

- If you want to execute the script at minute 15 of every hour: `15 * * * *`
- Every 30 minutes `*/30 * * * *`
- Every day at midnight `0 0 * * *`
- Every day at noon `0 12 * * *`
- Every day at 3, 4 and 6 PM `0 15,16,18 * * *`
- Every Wednesday at 9 AM `0 9 * * 3`

Verify your cron time [here](https://crontab.guru).

---

**Additional information**

The week starts on Sunday. 0 = SUN, 1 = MON ... 7 = SUN.

Be aware that if you set the day to '31', the script will only run on months that have the 31st day.

> [Learn more about running a cron job on the last day of the month here](https://stackoverflow.com/questions/6139189/cron-job-to-run-on-the-last-day-of-the-month) (expert).

The default filename for the exported channel is `YYYY-MM-DD-hh-mm-ss-yourfilename`. You can change it if you'd like.

Don't forget to update your token in the script after it has been reset!

---

Special thanks to [@Yudi](https://github.com/Yudi)


================================================
FILE: .docs/Scheduling-MacOS.md
================================================
# Scheduling exports on macOS

## Creating the script

1. Open TextEdit.app and create a new file

2. Convert the file to a plain text one in 'Format > Make Plain Text' (⇧⌘T)

![](https://i.imgur.com/WXrTtXM.png)

3. Paste the following into the text editor:

```bash
#!/bin/bash
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs

TOKEN=tokenhere
CHANNELID=channelhere
DLLFOLDER=dceFOLDERpathhere
FILENAME=filenamehere
EXPORTDIRECTORY=dirhere
EXPORTFORMAT=formathere
# Available export formats: plaintext, htmldark, htmllight, json, csv
# /\ CaSe-SeNsItIvE /\
# You can edit the export command on line 43 if you'd like to include more options like date ranges and date format. You can't use partitioning (-p) with this script.

# This variable specifies in which directories the executable programs are located. Don't change it.
PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/share/dotnet

# This will verify if EXPORTFORMAT is valid and will set the final file extension according to it. If the format is invalid, the script will display a message and exit.
if [[ "$EXPORTFORMAT" == "plaintext" ]]; then
FORMATEXT=.txt
elif [[ "$EXPORTFORMAT" == "htmldark" ]] || [[ "$EXPORTFORMAT" == "htmllight" ]]; then
FORMATEXT=.html
elif [[ "$EXPORTFORMAT" == "json" ]]; then
FORMATEXT=.json
elif [[ "$EXPORTFORMAT" == "csv" ]]; then
FORMATEXT=.csv
else
echo "$EXPORTFORMAT - Unknown export format"
echo "Available export formats: plaintext, htmldark, htmllight, csv, json"
echo "/\ CaSe-SeNsItIvE /\\"
exit 1
fi

# This will change the script's directory to DLLPATH, if unable to do so, the script will exit.
cd $DLLFOLDER || exit 1

# This will export your chat
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp

# This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")

# This will move the .tmp file to the desired export location. If unable to do so, it will attempt to delete the .tmp file.
if ! mv "$FILENAME.tmp" "${EXPORTDIRECTORY//\"}/$FILENAME-$CURRENTTIME$FORMATEXT" ; then
echo "Unable to move $FILENAME.tmp to $EXPORTDIRECTORY/$FILENAME-$CURRENTTIME$FORMATEXT."
echo "Cleaning up..."
  if ! rm -Rf "$FILENAME.tmp" ; then
  echo "Unable to remove $FILENAME.tmp."
  fi
exit 1
fi
exit 0
```

4. Replace:

- `tokenhere` with your [Token](Token-and-IDs.md)
- `channelhere` with a [Channel ID](Token-and-IDs.md)
- `dceFOLDERpathhere` with DCE's **directory's path** (e.g. `/Users/user/Desktop/DiscordChatExporterFolder`, NOT `/Users/user/Desktop/DiscordChatExporterFolder/DiscordChatExporter.DLL`)
- `filenamehere` with the exported channel's filename, without spaces
- `dirhere` with the directory you want the files to be saved at (e.g. `/Users/user/Documents/Discord\ Exports`)
- `formathere` with one of the available export formats

To quickly get file or folder paths, select the file/folder, then hit Command+I (⌘I) and copy what's after `Where:`.
After copying and pasting, make sure the file/folder name is not missing. If a folder has spaces in its name, add `\` before the spaces, like in the example below:

- `Discord\ Exports` - Wrong ✗
- `/Users/user/Documents` - Wrong ✗
- `/Users/user/Documents/Discord Exports` - Wrong ✗
- `/Users/user/Documents/Discord\ Exports/DCE.Cli.dll` - Wrong ✗
- `/Users/user/Documents/Discord \Exports` - Wrong ✗
- `/Users/user/Documents/Discord\ Exports` - Correct ✓
- `/Users/user/Desktop/DiscordChatExporter` - Correct ✓

![Screenshot of mac info window](https://i.imgur.com/29u6Nyx.png)

5. Save the file as `filename.sh`, not `.txt`
6. Open Terminal.app, type `chmod +x`, press the SPACE key, then drag & drop the `filename.sh` into the Terminal window and hit RETURN. You may be prompted for your password, and you won't be able to see it as you type.

## Creating the .plist file

Open TextEdit, make a Plain Text (⇧⌘T) and then paste the following into it:

```xml
<?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>Label</key>
    <string>local.discordchatexporter</string>
    <key>Program</key>
    <string>/path/to/filename.sh</string>
    REPLACEME
  </dict>
</plist>
```

- The `Label` string is the name of the export job, it must be something unique. Replace the `local.discordchatexporter` between the `<string>` with another name if you'd like to run more than one script.
- The `Program` string is the path to the script. Replace `/path/to/filename.sh` between the `<string>` with the path of the previously created script.
- Replace the `REPLACEME` with the content presented in the following sections according to <u>when</u> you want to export.

When you're done, save the file with the same name as the `Label` and with the `.plist` extension (not `.txt`), like `local.discordchatexporter.plist`.

### Exporting on System Boot/User Login

```xml
<key>RunAtLoad</key>
<true/>
```

### Export every _n_ seconds

The following example is to export every 3600 seconds (1 hour), replace the integer value with your desired time:

```xml
<key>StartInterval</key>
<integer>3600</integer>
```

### Export at a specific time and date

```xml
<key>StartCalendarInterval</key>
<dict>
  <key>Weekday</key>
  <integer>0</integer>
  <key>Month</key>
  <integer>0</integer>
  <key>Day</key>
  <integer>0</integer>
  <key>Hour</key>
  <integer>0</integer>
  <key>Minute</key>
  <integer>0</integer>
</dict>
```

| Key         | Integer           |
| ----------- | ----------------- |
| **Month**   | 1-12              |
| **Day**     | 1-31              |
| **Weekday** | 0-6 (0 is Sunday) |
| **Hour**    | 0-23              |
| **Minute**  | 0-59              |

**Sunday** - 0; **Monday** - 1; **Tuesday** - 2; **Wednesday** - 3; **Thursday** - 4; **Friday** - 5; **Saturday** - 6

Replace the template's `0`s according to the desired times.

You can delete the `<key>`s you don't need, don't forget to remove the `<integer>0</integer>` under it.
Omitted keys are interpreted as wildcards, for example, if you delete the Minute key, the script will run at every minute, delete the Weekday key and it'll run at every weekday, and so on.

Be aware that if you set the day to '31', the script will only run on months that have the 31st day.

**Check the examples below ([or skip to step 3 (loading the file)](#3-loading-the-plist-into-launchctl)):**

Export everyday at 5:15 PM:

```xml
<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>17</integer>
  <key>Minute</key>
  <integer>15</integer>
</dict>

```

Every 15 minutes of an hour (xx:15):

```xml
<key>StartCalendarInterval</key>
<dict>
  <key>Minute</key>
  <integer>15</integer>
</dict>

```

Every Sunday at midnight and every Wednesday full hour (xx:00). Notice the inclusion of `<array>` and `</array>` to allow multiple values:

```xml
<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Weekday</key>
    <integer>0</integer>
    <key>Hour</key>
    <integer>00</integer>
    <key>Minute</key>
    <integer>00</integer>
  </dict>
  <dict>
    <key>Weekday</key>
    <integer>3</integer>
    <key>Minute</key>
    <integer>00</integer>
  </dict>
</array>
```

## Loading the .plist into launchctl

1. Copy your `filename.plist` file to one of these folders according to how you want it to run:

- `~/Library/LaunchAgents` runs as the current logged-in user.

- `/Library/LaunchDaemons` runs as the system "_administrator_" (root).

- If macOS has a single user:
  - If you want to export only when the user is logged in, choose the first one.
  - If you want the script to always run on System Startup, choose the second one.
- If macOS has multiple users:
  - If you want the script to run only when a certain user is logged in, choose the first one.
  - If you want the script to always run on System Startup, choose the second one.

To quickly go to these directories, open Finder and press Command+Shift+G (⌘⇧G), then paste the path into the text box.

2. To load the job into launchctl, in Terminal, type `launchctl load`, press SPACE, drag and drop the `.plist` into the Terminal window, then hit RETURN. It won't output anything if it was successfully loaded.

### Extra launchctl commands

**Unloading a job**

```
launchctl unload /path/to/Library/LaunchAgents/local.discordchatexporter.plist
```

**List every loaded job**

```
launchctl list
```

**Check if a specific job is enabled**
You can also see error codes (2nd number) by running this command.

```
launchctl list | grep local.discordchatexporter
```

---

Further reading: [Script management with launchd in Terminal on Mac](https://support.apple.com/guide/terminal/script-management-with-launchd-apdc6c1077b-5d5d-4d35-9c19-60f2397b2369/mac) and [launchd.info](https://launchd.info/).
Special thanks to [@Yudi](https://github.com/Yudi)


================================================
FILE: .docs/Scheduling-Windows.md
================================================
# Scheduling exports on Windows

## Creating the script

1. Open a text editor such as Notepad and paste:

```console
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs

$TOKEN = "tokenhere"
$CHANNEL = "channelhere"
$EXEPATH = "exefolderhere"
$FILENAME = "filenamehere"
$EXPORTDIRECTORY = "dirhere"
$EXPORTFORMAT = "formathere"
# Available export formats: PlainText, HtmlDark, HtmlLight, Json, Csv

cd $EXEPATH

./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"

$Date = Get-Date -Format "yyyy-MM-dd-HH-mm"

If($EXPORTFORMAT -match "PlainText"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.txt"}
ElseIf($EXPORTFORMAT -match "HtmlDark"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.html"}
ElseIf($EXPORTFORMAT -match "HtmlLight"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.html"}
ElseIf($EXPORTFORMAT -match "Json"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.json"}
ElseIf($EXPORTFORMAT -match "Csv"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.csv"}
exit
```

2. Replace:

- `tokenhere` with your [Token](Token-and-IDs.md)
- `channelhere` with a [Channel ID](Token-and-IDs.md)
- `exefolderhere` with the .exe **directory's path** (e.g. C:\Users\User\Desktop\DiscordChatExporter)
- `filenamehere` with a filename without spaces
- `dirhere` with the export directory (e.g. C:\Users\User\Documents\Exports)
- `formathere` with one of the available export formats

Make sure not to delete the quotes (")

3. Save the file as `filename.ps1`, not as `.txt`

> **Note**: You can also modify the script to use other options, such as `include-threads` or switch to a different command, e. g. `exportguild`.

## Export at Startup

1. Press Windows + R, type `shell:startup` and press ENTER
2. Paste `filename.ps1` or a shortcut into this folder

## Scheduling with Task Scheduler

Please note that your computer must be turned on for the export to happen.

1. Press Windows + R, type `taskschd.msc` and press ENTER
2. Select `Task Scheduler Library`, create a Basic Task, and follow the instructions on-screen

<img src="https://i.imgur.com/MHRVGDi.png" height="500"/>

![Screenshot from Task Scheduler](https://i.imgur.com/m2DKhA8.png)

3. At 'Start a Program', write `powershell -file -ExecutionPolicy ByPass -WindowStyle Hidden "C:\path\to\filename.ps1"` in the Program/script text box

![](https://i.imgur.com/FGtWRod.png)

4. Click 'Yes'

![](https://i.imgur.com/DuaRBt3.png)

5. Click 'Finish'

![](https://i.imgur.com/LHgXp9Q.png)

---

Special thanks to [@Yudi](https://github.com/Yudi)


================================================
FILE: .docs/Token-and-IDs.md
================================================
# Obtaining Token and Channel IDs

> [!WARNING]
> **Do not share your token!** A token gives full access to an account.  
> To reset a user token, change your account password.  
> To reset a bot token, click on [Reset Token](#how-to-export-with-a-bot-token) in the bot settings.

## How to get a user token

**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.

### Through your web browser

Prerequisite step: Navigate to [discord.com](https://discord.com) and login.

#### In Chrome

##### Using the console

1. <img width="500" align="right" src="https://i.imgur.com/zdDwIT5.jpg" />Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>I</kbd> (<kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>I</kbd> on macOS). Chrome's [DevTools](https://developer.chrome.com/docs/devtools/overview) tools will display.

<br clear="right" />
<br />

2. Click the `Console` tab. The [console](https://developer.chrome.com/docs/devtools/console/) will open.

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?.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.

##### Using the network monitor

1. <img width="500" align="right" src="https://i.imgur.com/zdDwIT5.jpg" />Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>I</kbd> (<kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>I</kbd> on macOS). Chrome's [DevTools](https://developer.chrome.com/docs/devtools/overview) tools will display.

<br clear="right" />
<br />

2. <img width="500" align="right" src="https://i.imgur.com/BDeG0zg.png" />Click the `Network` tab. The [network panel](https://developer.chrome.com/docs/devtools/overview/#network) will open

<br clear="right" />
<br />

3. <img width="500" align="right" src="https://i.imgur.com/0Lgj0vk.png" />Press <kbd>F5</kbd>. The page will reload, and the network log (the lower half of the network panel) will display several entries.

<br clear="right" />
<br />

4. <img width="500" align="right" src="https://i.imgur.com/rnZG8Id.png" />Click the text box labelled `Filter` and type `messages`. The entries will filter down to a single request named `messages`. If the request doesn't appear, switch to any other Discord channel to trigger it.

<br clear="right" />
<br />

5. <img width="500" align="right" src="https://i.imgur.com/29dE3fR.png" />Click the entry named `messages`. A panel will open to the right and display details about the entry. Click the `Headers` tab if it isn't already active.

<br clear="right" />
<br />

6. <img width="500" align="right" src="https://i.imgur.com/u7CxXAt.png" />Scroll through the contents of the `Headers` tab until you find an entry beginning with `authorization:`.

<br clear="right" />
<br />

7. <img width="500" align="right" src="https://i.imgur.com/dXcXzma.png" />Right-click the entry and click `copy value`.

<br clear="right" />
<br />

##### Using the storage inspector

1. <img width="500" align="right" src="https://i.imgur.com/zdDwIT5.jpg" />Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>I</kbd> (<kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>I</kbd> on macOS). Chrome's [DevTools](https://developer.chrome.com/docs/devtools/overview/) will display.

<br clear="right" />
<br />

2. <img width="500" align="right" src="https://i.imgur.com/biAUIop.png" />Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd> (<kbd>⌘</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd>). Chrome will enter [Device Mode](https://developer.chrome.com/docs/devtools/device-mode/), and the webpage will display as if on a mobile device.

<br clear="right" />
<br />

3. <img width="500" align="right" src="https://i.imgur.com/oUDRZoy.png" />If necessary, click the `»` at the right end of the tab bar, and click `Application`. The [application panel](https://developer.chrome.com/docs/devtools/overview/#application) will display.

<br clear="right" />
<br />

4. <img width="500" align="right" src="https://i.imgur.com/sydNPia.png" />In the menu to the right, under `Storage`, expand `Local Storage` if necessary, then click `https://discord.com`. The pane to the right will display a list of key-value pairs.

<br clear="right" />
<br />

5. <img width="500" align="right" src="https://i.imgur.com/qKo0ny9.png" />In the text box marked `Filter`, type `token`. The entries will filter down to those containing the string `token`.

<br clear="right" />
<br />

6. <img width="500" align="right" src="https://i.imgur.com/caj3lQq.png" />Click the `token` entry. (Note: if the token doesn't display, try refreshing by pressing <kbd>F5</kbd> or <kbd>⌘</kbd>+<kbd>R</kbd> on macOS)

<br clear="right" />
<br />

7. <img width="500" align="right" src="https://i.imgur.com/SwWFIH4.png" />Click the text box at the bottom, press <kbd>Ctrl</kbd>+<kbd>A</kbd> (<kbd>⌘</kbd>+<kbd>A</kbd> on macOS) then <kbd>Ctrl</kbd>+<kbd>C</kbd> (<kbd>⌘</kbd>+<kbd>C</kbd> on macOS) to copy the value to your clipboard.

<br clear="right" />
<br />

#### In Firefox

##### Using the console

1. <img width="500" align="right" src="https://i.imgur.com/O34nwdG.png" />Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>K</kbd> (<kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>K</kbd> on macOS). Firefox’s [web developer tools](https://firefox-source-docs.mozilla.org/devtools-user/) will display at the bottom of the window, and the [web console](https://firefox-source-docs.mozilla.org/devtools-user/console/index.html) will display.

<br clear="right" />
<br />

2. Click the `Console` tab. The [console](https://firefox-source-docs.mozilla.org/devtools-user/console/index.html) will open.

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?.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.

##### Using the network monitor

1. <img width="500" align="right" src="https://i.imgur.com/O34nwdG.png" />Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> (<kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>E</kbd> on macOS). Firefox’s [web developer tools](https://firefox-source-docs.mozilla.org/devtools-user/) will display at the bottom of the window, and the [network monitor](https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/) will display.

<br clear="right" />
<br />

2. <img width="500" align="right" src="https://i.imgur.com/j00QzhU.png" />Press <kbd>F5</kbd>. The page will reload, and the [network request list](https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/request_list/index.html) will populate with entries.

<br clear="right" />
<br />

3. <img width="500" align="right" src="https://i.imgur.com/efUCfBO.png" />Type `messages` into the filter. The network request list will filter out any entries not containing the string `messages`. If the request doesn't appear, switch to any other Discord channel to trigger it.

<br clear="right" />
<br />

4. <img width="500" align="right" src="https://i.imgur.com/cdJZ7Q1.png" />Click `messages`. The [network request details pane](https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/request_details/index.html) will display. The [headers tab](https://firefox-source-docs.mozilla.org/devtools-user/network_monitor/request_details/index.html#network-monitor-request-details-headers-tab) should be active by default. If it isn’t, click it.

<br clear="right" />
<br />

5. <img width="500" align="right" src="https://i.imgur.com/zBmq1JW.png" />Type `authorization` into the text box labelled `Filter Headers`.

<br clear="right" />
<br />

6. <img width="500" align="right" src="https://i.imgur.com/O3blcIS.png" />Scroll down until you see an entry labeled [authorization](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) under `Request Headers`.

<br clear="right" />
<br />

7. <img width="500" align="right" src="https://i.imgur.com/zHYEYoZ.png" />Right-click the entry labeled `authorization` and select `copy value`.

<br clear="right" />
<br />

##### Using the storage inspector

1. <img width="500" align="right" src="https://i.imgur.com/A8jqpkm.png" />Press <kbd>Shift</kbd>+<kbd>F9</kbd>. Firefox’s [web developer tools](https://firefox-source-docs.mozilla.org/devtools-user/) will display at the bottom of the window, and the [storage](https://firefox-source-docs.mozilla.org/devtools-user/storage_inspector/index.html) panel will be selected.

<br clear="right" />
<br />

2. <img width="500" align="right" src="https://i.imgur.com/TGcbB7f.png" />Press <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd> (<kbd>⌥</kbd>+<kbd>⌘</kbd>+<kbd>M</kbd> on macOS). Firefox will toggle [responsive design mode](https://firefox-source-docs.mozilla.org/devtools-user/responsive_design_mode/), and the web page will display as if on a mobile device. (Note: Discord may steal focus and respond to the command by toggling mute. If this happens, return focus to Firefox’s web developer tools by clicking somewhere in it, then try the command again.)

<br clear="right" />
<br />

3. <img width="500" align="right" src="https://i.imgur.com/2xWkep9.png" />In the [storage tree](https://firefox-source-docs.mozilla.org/devtools-user/storage_inspector/index.html#storage-inspector-storage-tree) (the list on the left side of the web developer tools panel), click [Local Storage](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage). The entry will expand, and the entry `https://discord.com` will display beneath it.

<br clear="right" />
<br />

4. <img width="500" align="right" src="https://i.imgur.com/tGlGuOL.png" />In the storage tree, click `https://discord.com`. The [table widget](https://firefox-source-docs.mozilla.org/devtools-user/storage_inspector/index.html#storage-inspector-table-widget) to the right of the storage tree will display several key-value pairs.

<br clear="right" />
<br />

5. <img width="500" align="right" src="https://i.imgur.com/hDNsnZ5.png" />In the text box labelled `Filter items` at the top of the table widget, enter `token`. The table will now only display entries containing the string `token`.

<br clear="right" />
<br />

6. <img width="500" align="right" src="https://i.imgur.com/8fKId1W.png" />Click the entry `token`. The [sidebar](https://firefox-source-docs.mozilla.org/devtools-user/storage_inspector/index.html#storage-inspector-sidebar) will display. (Note: If the token doesn’t display, try refreshing by pressing <kbd>F5</kbd>.)

<br clear="right" />
<br />

7. <img width="500" align="right" src="https://i.imgur.com/yD1ZuR9.png" />Right-click the single entry in the sidebar and select `copy`.

<br clear="right" />
<br />

### Through the desktop app / enabling web developer tools

#### By editing the settings file

1. If Discord is running, exit the application by right-clicking the icon in your taskbar tray and clicking `Quit Discord`.

2. Open Discord's settings file in your preferred text editor. See the following table for help finding it:

   | OS      | Stable                                                | Canary                                                      | Public Test Build (PTB)                                  |
   | ------- | ----------------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------- |
   | Windows | `%APPDATA%\discord\settings.json`                     | `%APPDATA%\discordcanary\settings.json`                     | `%APPDATA%\discordptb\settings.json`                     |
   | macOS   | `~/Library/Application Support/discord/settings.json` | `~/Library/Application Support/discordcanary/settings.json` | `~/Library/Application Support/discordptb/settings.json` |
   | Linux   | `~/.config/discord/settings.json`                     | `~/.config/discordcanary/settings.json`                     | `~/.config/discordptb/settings.json`                     |

   If you use BetterDiscord, use the following table instead:

   | OS      | Stable                                                                  | Canary                                                                  | Public Test Build (PTB)                                              |
   | ------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
   | Windows | `%APPDATA%\BetterDiscord\data\stable\settings.json`                     | `%APPDATA%\BetterDiscord\data\canary\settings.json`                     | `%APPDATA%\BetterDiscord\data\ptb\settings.json`                     |
   | macOS   | `~/Library/Application Support/BetterDiscord/data/stable/settings.json` | `~/Library/Application Support/BetterDiscord/data/canary/settings.json` | `~/Library/Application Support/BetterDiscord/data/ptb/settings.json` |
   | Linux   | `~/.config/BetterDiscord/data/stable/settings.json`                     | `~/.config/BetterDiscord/data/canary/settings.json`                     | `~/.config/BetterDiscord/data/ptb/settings.json`                     |

3. Insert a blank line after the first curly bracket (`{`), add the text `"DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING": true,` to it, and save the file. Your file should resemble the following:

```json
{
  "DANGEROUS_ENABLE_DEVTOOLS_ONLY_ENABLE_IF_YOU_KNOW_WHAT_YOURE_DOING": true,
  "BACKGROUND_COLOR": "#202225",
  "IS_MAXIMIZED": true
}
```

4. Launch Discord.

5. To find your user token, continue [here](#in-chrome).

#### Via settings menu (BetterDiscord only)

1. <img width="500" align="right" src="https://i.imgur.com/mu1g4OF.png" />Click the User Settings button (the gear icon to the right of your username). Discord’s settings page will open.

<br clear="right" />
<br />

2. <img width="500" align="right" src="https://i.imgur.com/qFrIKON.png" />In the sidebar to the left, click `Settings` under the `BetterDiscord` group. BetterDiscord’s settings page will display.

<br clear="right" />
<br />

3. <img width="500" align="right" src="https://i.imgur.com/48Re5kj.png" />In the main panel to the right, expand the `Developer Settings` group if necessary, and toggle `DevTools` to enabled.

<br clear="right" />
<br />

4. Press <kbd>Esc</kbd>. The settings page will close.
5. To find your user token, continue [here](#in-chrome).

## How to export with a bot token

### Step 1 - Create an application

You can create a new application or use an existing one. If you want to create a new one:

1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Click on **New Application** in the top right corner
3. Enter a name for your application and click **Create**

### Step 2 - Invite the bot to your server

The bot needs to be invited to the server you'd like to export from.

1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Navigate to **General Information** on the left
3. Copy the **Application ID**
4. Open the following URL in your browser, replacing `YOUR_APP_ID` with the copied Client ID:

<!-- Permission code 66560 corresponds to "View Channels" and "Read Message History" permissions.
      User can uncheck these when adding the bot to their server. -->

```
https://discord.com/oauth2/authorize?scope=bot&permissions=66560&client_id=YOUR_APP_ID
```

### Step 3 - Ensure message content intent is enabled

If this option is not enabled, the exported files will be empty.

1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Open your Application's settings
3. Navigate to the **Bot** section on the left
4. Scroll down to the **Privileged Gateway Intents** section
5. Enable **Message Content Intent** by toggling the switch

<img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />

### Step 4 - Copy the bot token

If you don't have a bot token yet or if you've lost it, follow these steps to reset it:

1. Go to [Discord developer portal](https://discord.com/developers/applications)
2. Open your Application's settings
3. Navigate to the **Bot** section on the left
4. Under **Token** click **Reset Token**
5. Click **Yes, do it!** and authenticate to confirm

> **Tip**:
> As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.

> [!WARNING]
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.

![https://discord.com/developers/applications/](https://i.imgur.com/soiB8Qc.png)

---

## How to get a Server ID or a Channel ID

1. Open Discord Settings
2. Go to the **Advanced** section
3. Enable **Developer Mode**
4. Right-click on the desired server or channel and click **Copy Server ID** or **Copy Channel ID**


================================================
FILE: .docs/Troubleshooting.md
================================================
# Troubleshooting

Welcome to the Frequently Asked Questions (FAQ) and Troubleshooting page!
Here you'll find the answers to most of the questions related to **DiscordChatExporter** (DCE for short) and its core features.

- ❓ If you still have unanswered questions _after_ reading this page, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
- 🐞 If you have encountered a problem that's not described here, has not [been discussed before](https://github.com/Tyrrrz/DiscordChatExporter/discussions), and is not a [known issue](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue), please [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) or [open a bug report](https://github.com/Tyrrrz/DiscordChatExporter/issues/new). Don't forget to include your platform (Windows, Mac, Linux, etc.) and a detailed description of your question/problem.

## General questions

### Token stealer?

No. That's why this kind of software needs to be open-source, so the code can be audited by anyone.
Your token is only used to connect to Discord's API, it's not sent anywhere else.
If you're using the GUI, be aware that your token will be saved to a plain text file unless you disable it in the settings menu.

### Why should I be worried about the safety of my token?

A token can be used to log into your account, so treat it like a password and never share it.

### How can I reset my token?

Follow the [instructions here](Token-and-IDs.md).

### Will I get banned if I use this?

Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bot-users) don't have this restriction.

### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?

Text messages will not be removed from the exported file, but if media, such as images and user avatars, is changed or deleted, it will no longer be displayed. To avoid this, export using the "Download media" (`--media`) option.

### Can DCE export messages that have already been deleted?

No, DCE cannot access them since they have been permanently deleted from Discord's servers.

### Can DCE export private chats?

Yes, if your account has access to them.

### Can DCE download images?

Yes, and other media too. Export using the "Download media" (`--media`) option.

### Can the exported chats be shared?

Yes.

### Can DCE export multiple formats at once?

No, you can only export one format at a time.

### Can DCE recreate the exported chats in Discord?

No, DCE is an exporter.

### Can DCE reupload exported messages to another channel?

No, DCE is an exporter.

### Can DCE add new messages to an existing export?

No.

## First steps

### How can I find my token?

Check the following page: [Obtaining token](Token-and-IDs.md)

### When I open DCE a black window pops up quickly or nothing shows up

You might have downloaded the CLI flavor of the app, which is meant to be run in a terminal. Try [downloading the GUI](Getting-started.md#gui-or-cli) instead if that's what you want.

### How can I set DCE to export automatically at certain times?

Check the following pages to learn how to schedule **DiscordChatExporter.CLI** runs (advanced):

- [Windows scheduling](Scheduling-Windows.md)
- [macOS scheduling](Scheduling-MacOS.md)
- [Linux scheduling](Scheduling-Linux.md)

### The exported file is too large, I can't open it

Try opening it with a different program, try partitioning or use a different file format, like `PlainText`.

### I see messages in the export, but they have no content

Your bot is missing the 'Message Content Intent'. Go to the [Discord Developer Portal](https://discord.com/developers/applications), navigate to the 'Bot' section and enable it.

## CLI

### How do I use the CLI?

Check the following page:

- [Using the CLI](Using-the-CLI.md)

If you're using **Docker**, please refer to the [Docker Usage Instructions](Docker.md) instead.

### Where can I find the 'Channel IDs'?

Check the following page:

- [Obtaining Channel IDs](Token-and-IDs.md)

### I can't find Docker exported chats

Check the following page:

- [Docker usage instructions](Docker.md)

### I can't export Direct Messages

Make sure you're [copying the DM Channel ID](Token-and-IDs.md#how-to-get-a-direct-message-channel-id), not the person's user ID.

## Errors

```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid.
```

↳ Make sure the provided token is correct.

```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Requested resource does not exist.
```

↳ Check your channel ID, it might be invalid. [Read this if you need help](Token-and-IDs.md).

```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Access is forbidden.
```

↳ This means you don't have access to the channel.

```yml
System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server.
```

↳ Try running cert-sync.

Debian/Ubuntu: `cert-sync /etc/ssl/certs/ca-certificates.crt`

Red Hat: `cert-sync --user /etc/pki/tls/certs/ca-bundle.crt`

If it still doesn't work, try mozroots: `mozroots --import --ask-remove`

## macOS-specific

### DiscordChatExporter is damaged and can’t be opened. You should move it to the Trash.

Check the [Using the GUI page](Using-the-GUI.md#step-1) for instructions on how to run the app.

---

> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
>
> 🐞 If you have encountered a problem that's not described here, has not [been discussed before](https://github.com/Tyrrrz/DiscordChatExporter/discussions), and is not a [known issue](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue), please [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) or [open a bug report](https://github.com/Tyrrrz/DiscordChatExporter/issues/new).


================================================
FILE: .docs/Using-the-CLI.md
================================================
# Using the CLI

## Step 1

After extracting the `.zip` archive, open your preferred terminal.

## Step 2

Change the current directory to DCE's folder with `cd C:\path\to\DiscordChatExporter` (`cd /path/to/DiscordChatExporter` on **MacOS** and **Linux**), then press ENTER to run the command.

**Windows** users can quickly get the folder's path by clicking the address bar while inside the folder.
![Copy path from Explorer](https://i.imgur.com/XncnhC2.gif)

**macOS** users can press Command+Option+C (⌘⌥C) while inside the folder (or selecting it) to copy its path to the clipboard.

You can also drag and drop the folder on **every platform**.
![Drag and drop folder](https://i.imgur.com/sOpZQAb.gif)

## Step 3

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
```

> **Note**:
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.

> **Docker** users, please refer to the [Docker usage instructions](Docker.md).

## 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 |

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 get help with a specific command, run:

```console
./DiscordChatExporter.Cli command --help
```

For example, to figure out how to use the `export` command, run:

```console
./DiscordChatExporter.Cli export --help
```

## Export a specific channel

You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.

```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
```

#### Changing the format

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
```

#### Changing the output filename

You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:

```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
```

#### 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
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"
```

#### Changing the filename and output directory

You can change both the filename and export directory by using `-o directory\name.ext`.
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"
```

#### Generating the filename and output directory dynamically

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"
```

Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
called `"My server"`, you will get the following output file
path: `C:\Discord Exports\My server\Text channels\my-channel.html`

Here is the full list of supported template tokens:

- `%g` - server ID
- `%G` - server name
- `%t` - category ID
- `%T` - category name
- `%c` - channel ID
- `%C` - channel name
- `%p` - channel position
- `%P` - category position
- `%a` - the "after" date
- `%b` - the "before" date
- `%d` - the current date
- `%%` - escapes `%`

#### Partitioning

You can use partitioning to split files after a given number of messages or file size.
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
```

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
```

#### Downloading assets

If this option is set, the export will include additional files such as user avatars, attached files, images, etc.
Only files that are referenced by the export are downloaded, which means that, for example, user avatars will not be
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
```

#### Reusing assets

Previously downloaded assets can be reused to skip redundant downloads as long as the chat is always exported to the
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
```

#### Changing the media directory

By default, the media directory is created alongside the exported chat. You can change this by using `--media-dir` and
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"
```

#### Changing the date format

You can customize how dates are formatted in the exported files by using `--locale` and inserting one of Discord's
locales. The default locale is `en-US`.

```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
```

#### Date ranges

**Messages sent before a date**
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:

```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
```

**Messages sent after a date**
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:

```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
```

**Messages sent in a date range**
Use `--before` and `--after` to export messages sent during the provided date range. E.g. messages sent between
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"
```

You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
milliseconds `17-SEP-2019 23:45:30.6170`!
Don't forget to quote (") the date if it has spaces!
More info about .NET date
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).

#### Filtering messages

Use `--filter` to filter what messages are included in the export.

```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
```

Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/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:

```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
```

#### 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`.

```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
```

#### 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`.

```console
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
```

### Export all channels

To export all accessible channels, use the `exportall` command:

```console
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
```

#### Excluding DMs

To exclude DMs, add the `--include-dm false` option.

```console
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
```

### 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:

```console
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
```

### List direct message channels

To list all DM channels accessible to the current account, use the `dm` command:

```console
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
```

### List servers

To list all servers accessible by the current account, use the `guilds` command:

```console
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
```


================================================
FILE: .docs/Using-the-GUI.md
================================================
# Using the GUI

## Video tutorial

[![Video tutorial](https://i.ytimg.com/vi/jjtu0VQXV7I/hqdefault.jpg)](https://youtube.com/watch?v=jjtu0VQXV7I)

> Video by [NoIntro Tutorials](https://youtube.com/channel/UCFezKSxdNKJe77-hYiuXu3Q).

## Guide

### Step 1

After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.

If you're using **macOS**, you'll need to manually grant permission for the app to run.  
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.

1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
2. Paste the following into the terminal window:
   ```bash
   xattr -rd com.apple.quarantine
   ```
3. Hit <kbd>Space</kbd> once to add a space after the command
4. Drag and drop DiscordChatExporter.app into the terminal window
5. Press <kbd>Return</kbd> to run the command
6. Open DiscordChatExporter.app normally

> Apple requires apps to be notarized and signed in order to run on macOS without warnings, which in turn requires an Apple Developer membership ($99/year). This open-source project is distributed for free and without commercial intent.

### Step 2

Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).

> [!WARNING]
> **Never share your token!**
> A token gives full access to an account, treat it like a password.

<img src="https://i.imgur.com/SuLQ5tZ.png" height="400"/>

### Step 3

DCE will display your Direct Messages and a sidebar with your server list. Select the channel you would like to export, then click the ![Screenshot](https://i.imgur.com/dnTOlDa.png) button to continue.

> **Note**:
> You can export multiple channels at once by holding `CTRL` or `SHIFT` while selecting.
> You can also double-click a channel to export it without clicking the ![Screenshot](https://i.imgur.com/dnTOlDa.png) button.

<img src="https://i.imgur.com/JHMFRh2.png" height="400"/>

### Step 4

In this screen you can customize the following:

- **Output path** - The folder where the exported chat(s) will be saved.

- **Export format** - HTML (Dark), HTML (Light), TXT, CSV and JSON

- **Date range (after/before)** (Optional) - If set, only messages sent in the provided date range will be exported. Only one value (either after or before) is required if you want to use this option.
  > **Note**:
  > Please note that the time defaults to **12:00 AM** (midnight/00:00). This means that if you choose to export between Sep 17th and Sep 18th, messages from Sep 18th won't be exported.

- **Partition limit** (Optional) - Split output into partitions, each limited to this number of messages (e.g. 100) or file size (e.g. 10mb). For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.

- **Message Filter** (Optional) - Special notation for filtering the messages that get included in the export. See [Message filters](Message-filters.md) for more info.

- **Format markdown** (Optional) - Disable markdown processing when exporting. You can use this to produce JSON or plain text exports without unwrapping mentions, custom emoji, and certain other special tokens.

- **Download assets** (Optional) - If this option is set, the export will include additional files such as user avatars, attached files, images, etc. Only files that are referenced by the export are downloaded, which means that, for example, user avatars will not be 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.

- **Reuse assets** (Optional) - If this option is set, the export will reuse already downloaded assets to skip redundant requests. This option is only available when **Download assets** is enabled.

- **Assets directory path** (Optional) - If this option is set, the export will use the specified directory to store assets from all exported channels in the same place.

> **Note**:
> You need to scroll down to see all available options.

## Settings

- **Auto-update** - Perform automatic updates on every launch.
Default: Enabled

  > **Note**:
  > Keep this option enabled to receive the latest features and bug fixes!

- **Dark mode** - Use darker colors in the UI (User Interface).
Default: Disabled

- **Persist token** - Persist last used token between sessions.
Default: Enabled

- **Show threads** - Controls whether threads are shown in the channel list.
Default: none

- **Locale** - Customize how dates are formatted in the exported files.

- **Date format** - Customize how dates are formatted in the exported files in the settings menu ().

- **Parallel limit** - The number of channels that will be exported at the same time.
Default: 1

  > **Note**:
  > Try to keep this number low so that your account doesn't get flagged.

- **Normalize to UTC** - Convert all dates to UTC before exporting.



================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.yml
================================================
name: 🐛 Bug report
description: Report broken functionality.
labels: [bug]

body:
  - type: markdown
    attributes:
      value: |
        - Avoid generic or vague titles such as "Something's not working" or "A couple of problems" — be as descriptive as possible.
        - Keep your issue focused on one single problem. If you have multiple bug reports, please create a separate issue for each of them.
        - Issues should represent **complete and actionable** work items. If you are unsure about something or have a question, please start a [discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
        - Remember that **DiscordChatExporter** is an open-source project funded by the community. If you find it useful, **please consider [donating](https://tyrrrz.me/donate) to support its development**.

        ___

  - type: input
    attributes:
      label: Version
      description: Which version of the application does this bug affect? Make sure you're not using an outdated version.
      placeholder: v1.0.0
    validations:
      required: true

  - type: dropdown
    attributes:
      label: Flavor
      description: Which flavor(s) of the application does this bug affect?
      multiple: true
      options:
        - GUI (Graphical User Interface)
        - CLI (Command-Line Interface)
    validations:
      required: true

  - type: input
    attributes:
      label: Platform
      description: Which platform do you experience this bug on?
      placeholder: Docker / Windows 11
    validations:
      required: true

  - type: dropdown
    attributes:
      label: Export format
      description: Which export format(s) do you experience this bug with, if applicable?
      multiple: true
      options:
        - HTML
        - TXT
        - JSON
        - CSV

  - type: textarea
    attributes:
      label: Steps to reproduce
      description: >
        Minimum steps required to reproduce the bug, including prerequisites, export settings, or other relevant items.
        The information provided in this field must be readily actionable, meaning that anyone should be able to reproduce the bug by following these steps.
        If the bug depends on external factors (such as a specific server, channel, or message), then this field must include the server invite and the corresponding link.
      placeholder: |
        Server invite: https://discord.gg/...
        Channel or message link: https://discord.com/channels/.../...

        Export settings:
        - ...

        Application settings:
        - ...

        Steps:
        - Step 1
        - Step 2
        - Step 3
    validations:
      required: true

  - type: textarea
    attributes:
      label: Details
      description: Clear and thorough explanation of the bug, including any additional information you may find relevant.
      placeholder: |
        - Expected behavior: ...
        - Actual behavior: ...
    validations:
      required: true

  - type: checkboxes
    attributes:
      label: Checklist
      description: Quick list of checks to ensure that everything is in order.
      options:
        - label: I have looked through existing issues to make sure that this bug has not been reported before
          required: true
        - label: I have provided a descriptive title for this issue
          required: true
        - label: I have made sure that this bug is reproducible on the latest version of the application
          required: true
        - label: I have provided all the information needed to reproduce this bug as efficiently as possible
          required: true
        - label: I have sponsored this project
          required: false
        - label: I have not read any of the above and just checked all the boxes to submit the issue
          required: false

  - type: markdown
    attributes:
      value: |
        If you are struggling to provide actionable reproduction steps, or if something else is preventing you from creating a complete bug report, please start a [discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.


================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
blank_issues_enabled: false
contact_links:
  - name: ⚠ Feature request
    url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
    about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
  - name: 📖 Documentation
    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
    about: Ask and answer questions.
  - name: 💬 Discord server
    url: https://discord.gg/2SUWKFnHSm
    about: Chat with the project community.


================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
  - package-ecosystem: github-actions
    directory: "/"
    schedule:
      interval: monthly
    labels:
      - enhancement
    groups:
      actions:
        patterns:
          - "*"
  - package-ecosystem: docker
    directory: "/"
    schedule:
      interval: monthly
    labels:
      - enhancement
    groups:
      docker:
        patterns:
          - "*"
  - package-ecosystem: nuget
    directory: "/"
    schedule:
      interval: monthly
    labels:
      - enhancement
    groups:
      nuget:
        patterns:
          - "*"


================================================
FILE: .github/workflows/docker.yml
================================================
name: docker

on:
  workflow_dispatch:
  push:
    branches:
      - prime
    tags:
      - "*"
  pull_request:
    branches:
      - prime

jobs:
  # Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
  pack:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    permissions:
      actions: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Build image
        run: >
          docker buildx build .
          --file DiscordChatExporter.Cli.dockerfile
          --platform linux/amd64,linux/arm64
          --build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
          --output type=tar,dest=DiscordChatExporter.Cli.Docker.tar

      - name: Upload image
        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 prime branch push
    if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'prime' }}

    runs-on: ubuntu-latest
    timeout-minutes: 10

    permissions:
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Login to DockerHub
        run: >
          echo ${{ secrets.DOCKER_TOKEN }} |
          docker login --username tyrrrz --password-stdin

      - name: Build & push image
        run: >
          docker buildx build .
          --file DiscordChatExporter.Cli.dockerfile
          --platform linux/amd64,linux/arm64
          --build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
          --push
          --tag tyrrrz/discordchatexporter:latest
          ${{ github.ref_type == 'tag' && '--tag tyrrrz/discordchatexporter:$GITHUB_REF_NAME' || '' }}
          ${{ github.ref_type == 'tag' && '--tag tyrrrz/discordchatexporter:stable' || '' }}


================================================
FILE: .github/workflows/main.yml
================================================
name: main

on:
  workflow_dispatch:
  push:
    branches:
      - prime
    tags:
      - "*"
  pull_request:
    branches:
      - prime

env:
  DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
  DOTNET_NOLOGO: true
  DOTNET_CLI_TELEMETRY_OPTOUT: true

jobs:
  format:
    runs-on: ubuntu-latest
    timeout-minutes: 10

    permissions:
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install .NET
        uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0

      # Build the project separately to discern between build and format errors
      - name: Build
        run: >
          dotnet build
          -p:CSharpier_Bypass=true
          --configuration Release

      - name: Verify formatting
        id: verify
        run: >
          dotnet build
          -t:CSharpierFormat
          --configuration Release
          --no-restore

      - name: Report issues
        if: ${{ failure() && steps.verify.outcome == 'failure' }}
        run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them."

  test:
    # Tests need access to secrets, so we can't run them against PRs because of limited trust
    if: ${{ github.event_name != 'pull_request' }}

    runs-on: ubuntu-latest
    timeout-minutes: 10

    permissions:
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install .NET
        uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0

      - name: Run tests
        env:
          DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
        run: >
          dotnet test
          -p:CSharpier_Bypass=true
          --configuration Release
          --logger "GitHubActions;summary.includePassedTests=true;summary.includeSkippedTests=true"
          --collect:"XPlat Code Coverage"
          --
          RunConfiguration.CollectSourceInformation=true
          DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover

      - name: Upload coverage
        uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  pack:
    strategy:
      matrix:
        app:
          - DiscordChatExporter.Cli
          - DiscordChatExporter.Gui
        rid:
          - win-arm64
          - win-x86
          - win-x64
          - linux-arm
          - linux-arm64
          - linux-musl-x64
          - linux-x64
          - osx-arm64
          - osx-x64
        include:
          - app: DiscordChatExporter.Cli
            asset: DiscordChatExporter.Cli
          - app: DiscordChatExporter.Gui
            # GUI assets aren't suffixed, unlike the CLI assets
            asset: DiscordChatExporter

    runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }}
    timeout-minutes: 10

    permissions:
      actions: write
      contents: read

    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Install .NET
        uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0

      - name: Publish app
        run: >
          dotnet publish ${{ matrix.app }}
          -p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
          -p:CSharpier_Bypass=true
          -p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
          -p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
          --output ${{ matrix.app }}/bin/publish/
          --configuration Release
          --runtime ${{ matrix.rid }}
          --self-contained

      - name: Upload app binaries
        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
        with:
          name: ${{ matrix.asset }}.${{ matrix.rid }}
          path: ${{ matrix.app }}/bin/publish/
          if-no-files-found: error

  release:
    if: ${{ github.ref_type == 'tag' }}

    needs:
      - format
      - test
      - pack

    runs-on: ubuntu-latest
    timeout-minutes: 10

    permissions:
      contents: write

    steps:
      - name: Create release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: >
          gh release create ${{ github.ref_name }}
          --repo ${{ github.event.repository.full_name }}
          --title ${{ github.ref_name }}
          --generate-notes
          --verify-tag

  deploy:
    needs: release

    strategy:
      matrix:
        app:
          - DiscordChatExporter.Cli
          - DiscordChatExporter.Gui
        rid:
          - win-arm64
          - win-x86
          - win-x64
          - linux-arm
          - linux-arm64
          - linux-musl-x64
          - linux-x64
          - osx-arm64
          - osx-x64
        include:
          - app: DiscordChatExporter.Cli
            asset: DiscordChatExporter.Cli
          - app: DiscordChatExporter.Gui
            # GUI assets aren't suffixed, unlike the CLI assets
            asset: DiscordChatExporter

    runs-on: ubuntu-latest
    timeout-minutes: 10

    permissions:
      actions: read
      contents: write

    steps:
      - name: Download app binaries
        uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
        with:
          name: ${{ matrix.asset }}.${{ matrix.rid }}
          path: ${{ matrix.app }}/

      - name: Set permissions
        if: ${{ !startsWith(matrix.rid, 'win-') }}
        run: |
          [ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true

          # macOS bundle
          [ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true

      - name: Create package
        # Change into the artifacts directory to avoid including the directory itself in the zip archive
        working-directory: ${{ matrix.app }}/
        run: zip -r ../${{ matrix.asset }}.${{ matrix.rid }}.zip .

      - name: Upload release asset
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: >
          gh release upload ${{ github.ref_name }}
          ${{ matrix.asset }}.${{ matrix.rid }}.zip
          --repo ${{ github.event.repository.full_name }}

  notify:
    needs: deploy
    runs-on: ubuntu-latest
    timeout-minutes: 10

    permissions:
      contents: read

    steps:
      - name: Notify Discord
        uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
        with:
          url: ${{ secrets.DISCORD_WEBHOOK }}
          method: POST
          headers: |
            Content-Type: application/json; charset=UTF-8
          body: |
            {
              "avatar_url": "https://raw.githubusercontent.com/${{ github.event.repository.full_name }}/${{ github.ref_name }}/favicon.png",
              "content": "[**${{ github.event.repository.name }}**](<${{ github.event.repository.html_url }}>) v${{ github.ref_name }} has been released!"
            }
          retry-count: 5


================================================
FILE: .gitignore
================================================
# User-specific files
.vs/
.idea/
*.suo
*.user

# Build results
bin/
obj/

# Test results
TestResults/


================================================
FILE: Directory.Build.props
================================================
<Project>

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Version>999.9.9-dev</Version>
    <Company>Tyrrrz</Company>
    <Copyright>Copyright (c) Oleksii Holub</Copyright>
    <LangVersion>preview</LangVersion>
    <Nullable>enable</Nullable>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
  </PropertyGroup>

</Project>

================================================
FILE: Directory.Packages.props
================================================
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>

  <ItemGroup>
    <PackageVersion Include="AngleSharp" Version="1.4.0" />
    <PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.6.0" />
    <PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
    <PackageVersion Include="Avalonia" Version="11.3.12" />
    <PackageVersion Include="Avalonia.Desktop" Version="11.3.12" />
    <PackageVersion Include="Avalonia.Diagnostics" Version="11.3.12" />
    <PackageVersion Include="CliFx" Version="2.3.6" />
    <PackageVersion Include="Cogwheel" Version="2.1.0" />
    <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
    <PackageVersion Include="coverlet.collector" Version="8.0.0" />
    <PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
    <PackageVersion Include="Deorcify" Version="1.1.0" />
    <PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
    <PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
    <PackageVersion Include="FluentAssertions" Version="8.8.0" />
    <PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
    <PackageVersion Include="Gress" Version="2.1.1" />
    <PackageVersion Include="JsonExtensions" Version="1.2.0" />
    <PackageVersion Include="Markdig" Version="1.0.1" />
    <PackageVersion Include="Material.Avalonia" Version="3.9.2" />
    <PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
    <PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
    <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.3" />
    <PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.3" />
    <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
    <PackageVersion Include="Onova" Version="2.6.13" />
    <PackageVersion Include="Polly" Version="8.6.5" />
    <PackageVersion Include="RazorBlade" Version="0.11.0" />
    <PackageVersion Include="Spectre.Console" Version="0.54.0" />
    <PackageVersion Include="Superpower" Version="3.1.0" />
    <PackageVersion Include="WebMarkupMin.Core" Version="2.20.2" />
    <PackageVersion Include="xunit" Version="2.9.3" />
    <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
    <PackageVersion Include="YoutubeExplode" Version="6.5.7" />
  </ItemGroup>
</Project>


================================================
FILE: DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs
================================================
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;

namespace DiscordChatExporter.Cli.Commands.Base;

public abstract class DiscordCommandBase : ICommand
{
    [CommandOption(
        "token",
        't',
        EnvironmentVariable = "DISCORD_TOKEN",
        Description = "Authentication token."
    )]
    public required string Token { get; init; }

    [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;

    [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;

    [field: AllowNull, MaybeNull]
    protected DiscordClient Discord =>
        field ??= new DiscordClient(
            Token,
            ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
        );

    public virtual ValueTask ExecuteAsync(IConsole console)
    {
#pragma warning disable CS0618
        // Warn if the bot option is used
        if (IsBotToken)
        {
            using (console.WithForegroundColor(ConsoleColor.DarkYellow))
            {
                console.Error.WriteLine(
                    "Warning: The --bot option is deprecated and should not be used. "
                        + "The token type is now inferred automatically. "
                        + "Please update your workflows as this option may be completely removed in a future version."
                );
            }
        }
#pragma warning restore CS0618

        // Note about interactivity for Docker
        if (console.IsOutputRedirected && Docker.IsRunningInContainer)
        {
            console.Error.WriteLine(
                "Note: Output streams are redirected, rich console interactions are disabled. "
                    + "If you are running this command in Docker, consider allocating a pseudo-terminal for better user experience (docker run -it ...)."
            );
        }

        return default;
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs
================================================
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using Gress;
using Spectre.Console;

namespace DiscordChatExporter.Cli.Commands.Base;

public abstract class ExportCommandBase : DiscordCommandBase
{
    [CommandOption(
        "output",
        'o',
        Description = "Output file or directory path. "
            + "If a directory is specified, file names will be generated automatically based on the channel names and export parameters. "
            + "Directory paths must end with a slash to avoid ambiguity. "
            + "Supports template tokens, see the documentation for more info."
    )]
    public string OutputPath
    {
        get;
        // Handle ~/ in paths on Unix systems
        // https://github.com/Tyrrrz/DiscordChatExporter/pull/903
        init => field = Path.GetFullPath(value);
    } = Directory.GetCurrentDirectory();

    [CommandOption("format", 'f', Description = "Export format.")]
    public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;

    [CommandOption(
        "after",
        Description = "Only include messages sent after this date or message ID."
    )]
    public Snowflake? After { get; init; }

    [CommandOption(
        "before",
        Description = "Only include messages sent before this date or message ID."
    )]
    public Snowflake? Before { get; init; }

    [CommandOption(
        "partition",
        'p',
        Description = "Split the output into partitions, each limited to the specified "
            + "number of messages (e.g. '100') or file size (e.g. '10mb')."
    )]
    public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;

    [CommandOption(
        "include-threads",
        Description = "Which types of threads should be included.",
        Converter = typeof(ThreadInclusionModeBindingConverter)
    )]
    public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;

    [CommandOption(
        "filter",
        Description = "Only include messages that satisfy this filter. "
            + "See the documentation for more info."
    )]
    public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;

    [CommandOption(
        "parallel",
        Description = "Limits how many channels can be exported in parallel."
    )]
    public int ParallelLimit { get; init; } = 1;

    [CommandOption(
        "reverse",
        Description = "Export messages in reverse chronological order (newest first)."
    )]
    public bool IsReverseMessageOrder { get; init; }

    [CommandOption(
        "markdown",
        Description = "Process markdown, mentions, and other special tokens."
    )]
    public bool ShouldFormatMarkdown { get; init; } = true;

    [CommandOption(
        "media",
        Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
    )]
    public bool ShouldDownloadAssets { get; init; }

    [CommandOption(
        "reuse-media",
        Description = "Reuse previously downloaded assets to avoid redundant requests."
    )]
    public bool ShouldReuseAssets { get; init; } = false;

    [CommandOption(
        "media-dir",
        Description = "Download assets to this directory. "
            + "If not specified, the asset directory path will be derived from the output path."
    )]
    public string? AssetsDirPath
    {
        get;
        // Handle ~/ in paths on Unix systems
        // https://github.com/Tyrrrz/DiscordChatExporter/pull/903
        init => 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";

    [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; }

    [CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
    public bool IsUtcNormalizationEnabled { get; init; } = 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)
    )]
    public bool IsUkraineSupportMessageDisabled { get; init; } = false;

    [field: AllowNull, MaybeNull]
    protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);

    protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
    {
        var cancellationToken = console.RegisterCancellationHandler();

        // Asset reuse can only be enabled if the download assets option is set
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/425
        if (ShouldReuseAssets && !ShouldDownloadAssets)
        {
            throw new CommandException("Option --reuse-media cannot be used without --media.");
        }

        // Assets directory can only be specified if the download assets option is set
        if (!string.IsNullOrWhiteSpace(AssetsDirPath) && !ShouldDownloadAssets)
        {
            throw new CommandException("Option --media-dir cannot be used without --media.");
        }

        var unwrappedChannels = new List<Channel>(channels);

        // Unwrap threads
        if (ThreadInclusionMode != ThreadInclusionMode.None)
        {
            await console.Output.WriteLineAsync("Fetching threads...");

            var fetchedThreadsCount = 0;
            await console
                .CreateStatusTicker()
                .StartAsync(
                    "...",
                    async ctx =>
                    {
                        await foreach (
                            var thread in Discord.GetChannelThreadsAsync(
                                channels,
                                ThreadInclusionMode == ThreadInclusionMode.All,
                                Before,
                                After,
                                cancellationToken
                            )
                        )
                        {
                            unwrappedChannels.Add(thread);

                            ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));

                            fetchedThreadsCount++;
                        }
                    }
                );

            // Remove forums, as they cannot be exported directly and their constituent threads
            // have already been fetched.
            unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);

            await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
        }

        // Make sure the user does not try to export multiple channels into one file.
        // Output path must either be a directory or contain template tokens for this to work.
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/799
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/917
        var isValidOutputPath =
            // Anything is valid when exporting a single channel
            unwrappedChannels.Count <= 1
            // When using template tokens, assume the user knows what they're doing
            || OutputPath.Contains('%')
            // Otherwise, require an existing directory or an unambiguous directory path
            || Directory.Exists(OutputPath)
            || Path.EndsInDirectorySeparator(OutputPath);

        if (!isValidOutputPath)
        {
            throw new CommandException(
                "Attempted to export multiple channels, but the output path is neither a directory nor a template. "
                    + "If the provided output path is meant to be treated as a directory, make sure it ends with a slash. "
                    + $"Provided output path: '{OutputPath}'."
            );
        }

        // Export
        var errorsByChannel = new ConcurrentDictionary<Channel, string>();
        var warningsByChannel = new ConcurrentDictionary<Channel, string>();

        await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
        await console
            .CreateProgressTicker()
            .HideCompleted(
                // When exporting multiple channels in parallel, hide the completed tasks
                // because it gets hard to visually parse them as they complete out of order.
                // https://github.com/Tyrrrz/DiscordChatExporter/issues/1124
                ParallelLimit > 1
            )
            .StartAsync(async ctx =>
            {
                await Parallel.ForEachAsync(
                    unwrappedChannels,
                    new ParallelOptions
                    {
                        MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
                        CancellationToken = cancellationToken,
                    },
                    async (channel, innerCancellationToken) =>
                    {
                        try
                        {
                            await ctx.StartTaskAsync(
                                Markup.Escape(channel.GetHierarchicalName()),
                                async progress =>
                                {
                                    var guild = await Discord.GetGuildAsync(
                                        channel.GuildId,
                                        innerCancellationToken
                                    );

                                    var request = new ExportRequest(
                                        guild,
                                        channel,
                                        OutputPath,
                                        AssetsDirPath,
                                        ExportFormat,
                                        After,
                                        Before,
                                        PartitionLimit,
                                        MessageFilter,
                                        IsReverseMessageOrder,
                                        ShouldFormatMarkdown,
                                        ShouldDownloadAssets,
                                        ShouldReuseAssets,
                                        Locale,
                                        IsUtcNormalizationEnabled
                                    );

                                    await Exporter.ExportChannelAsync(
                                        request,
                                        progress.ToPercentageBased(),
                                        innerCancellationToken
                                    );
                                }
                            );
                        }
                        catch (ChannelEmptyException ex)
                        {
                            warningsByChannel[channel] = ex.Message;
                        }
                        catch (DiscordChatExporterException ex) when (!ex.IsFatal)
                        {
                            errorsByChannel[channel] = ex.Message;
                        }
                    }
                );
            });

        // Print the result
        using (console.WithForegroundColor(ConsoleColor.White))
        {
            await console.Output.WriteLineAsync(
                $"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
            );
        }

        // Print warnings
        if (warningsByChannel.Any())
        {
            await console.Output.WriteLineAsync();

            using (console.WithForegroundColor(ConsoleColor.Yellow))
            {
                await console.Error.WriteLineAsync(
                    "Warnings reported for the following channel(s):"
                );
            }

            foreach (var (channel, message) in warningsByChannel)
            {
                await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
                using (console.WithForegroundColor(ConsoleColor.Yellow))
                    await console.Error.WriteLineAsync(message);
            }

            await console.Error.WriteLineAsync();
        }

        // Print errors
        if (errorsByChannel.Any())
        {
            await console.Output.WriteLineAsync();

            using (console.WithForegroundColor(ConsoleColor.Red))
            {
                await console.Error.WriteLineAsync("Failed to export the following channel(s):");
            }

            foreach (var (channel, message) in errorsByChannel)
            {
                await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
                using (console.WithForegroundColor(ConsoleColor.Red))
                    await console.Error.WriteLineAsync(message);
            }

            await console.Error.WriteLineAsync();
        }

        // Fail the command only if ALL channels failed to export.
        // If only some channels failed to export, it's okay.
        if (errorsByChannel.Count >= unwrappedChannels.Count)
            throw new CommandException("Export failed.");
    }

    public override async ValueTask ExecuteAsync(IConsole console)
    {
        // Support Ukraine callout
        if (!IsUkraineSupportMessageDisabled)
        {
            console.Output.WriteLine(
                "┌────────────────────────────────────────────────────────────────────┐"
            );
            console.Output.WriteLine(
                "│   Thank you for supporting Ukraine <3                              │"
            );
            console.Output.WriteLine(
                "│                                                                    │"
            );
            console.Output.WriteLine(
                "│   As Russia wages a genocidal war against my country,              │"
            );
            console.Output.WriteLine(
                "│   I'm grateful to everyone who continues to                        │"
            );
            console.Output.WriteLine(
                "│   stand with Ukraine in our fight for freedom.                     │"
            );
            console.Output.WriteLine(
                "│                                                                    │"
            );
            console.Output.WriteLine(
                "│   Learn more: https://tyrrrz.me/ukraine                            │"
            );
            console.Output.WriteLine(
                "└────────────────────────────────────────────────────────────────────┘"
            );
            console.Output.WriteLine("");
        }

        await base.ExecuteAsync(console);
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/Converters/ThreadInclusionModeBindingConverter.cs
================================================
using System;
using CliFx.Extensibility;
using DiscordChatExporter.Cli.Commands.Shared;

namespace DiscordChatExporter.Cli.Commands.Converters;

internal class ThreadInclusionModeBindingConverter : BindingConverter<ThreadInclusionMode>
{
    public override ThreadInclusionMode Convert(string? rawValue)
    {
        // Empty or unset value is treated as 'active' to match the previous behavior
        if (string.IsNullOrWhiteSpace(rawValue))
            return ThreadInclusionMode.Active;

        // Boolean 'true' is treated as 'active', boolean 'false' is treated as 'none'
        if (bool.TryParse(rawValue, out var boolValue))
            return boolValue ? ThreadInclusionMode.Active : ThreadInclusionMode.None;

        // Otherwise, fall back to regular enum parsing
        return Enum.Parse<ThreadInclusionMode>(rawValue, true);
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/Converters/TruthyBooleanBindingConverter.cs
================================================
using System.Globalization;
using CliFx.Extensibility;

namespace DiscordChatExporter.Cli.Commands.Converters;

internal class TruthyBooleanBindingConverter : BindingConverter<bool>
{
    public override bool Convert(string? rawValue)
    {
        // Empty or unset value is treated as 'true', to match the regular boolean behavior
        if (string.IsNullOrWhiteSpace(rawValue))
            return true;

        // Number '1' is treated as 'true', other numbers are treated as 'false'
        if (int.TryParse(rawValue, CultureInfo.InvariantCulture, out var intValue))
            return intValue == 1;

        // Otherwise, fall back to regular boolean parsing
        return bool.Parse(rawValue);
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/ExportAllCommand.cs
================================================
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);
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs
================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
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
{
    // TODO: change this to plural (breaking change)
    [CommandOption(
        "channel",
        'c',
        Description = "Channel ID(s). "
            + "If provided with category ID(s), all channels inside those categories will be exported."
    )]
    public required IReadOnlyList<Snowflake> ChannelIds { get; init; }

    public override async ValueTask ExecuteAsync(IConsole console)
    {
        await base.ExecuteAsync(console);

        var cancellationToken = console.RegisterCancellationHandler();

        await console.Output.WriteLineAsync("Resolving channel(s)...");

        var channels = new List<Channel>();
        var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();

        foreach (var channelId in ChannelIds)
        {
            var channel = await Discord.GetChannelAsync(channelId, cancellationToken);

            // Unwrap categories
            if (channel.IsCategory)
            {
                var guildChannels =
                    channelsByGuild.GetValueOrDefault(channel.GuildId)
                    ?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);

                foreach (var guildChannel in guildChannels)
                {
                    if (guildChannel.Parent?.Id == channel.Id)
                        channels.Add(guildChannel);
                }

                // Cache the guild channels to avoid redundant work
                channelsByGuild[channel.GuildId] = guildChannels;
            }
            else
            {
                channels.Add(channel);
            }
        }

        await ExportAsync(console, channels);
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs
================================================
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);
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs
================================================
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);
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs
================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;

namespace DiscordChatExporter.Cli.Commands;

[Command("channels", Description = "Get the list of channels in a server.")]
public class GetChannelsCommand : DiscordCommandBase
{
    [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;

    [CommandOption(
        "include-threads",
        Description = "Which types of threads should be included.",
        Converter = typeof(ThreadInclusionModeBindingConverter)
    )]
    public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;

    public override async ValueTask ExecuteAsync(IConsole console)
    {
        await base.ExecuteAsync(console);

        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 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)
        {
            // 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());

            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)
            {
                // 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"
                    );
            }
        }
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/GetDirectChannelsCommand.cs
================================================
using System;
using System.Linq;
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("dm", Description = "Gets the list of all direct message channels.")]
public class GetDirectChannelsCommand : DiscordCommandBase
{
    public override async ValueTask ExecuteAsync(IConsole console)
    {
        await base.ExecuteAsync(console);

        var cancellationToken = console.RegisterCancellationHandler();

        var channels = (
            await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken)
        )
            .OrderByDescending(c => c.LastMessageId)
            .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());
        }
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs
================================================
using System;
using System.Linq;
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("guilds", Description = "Gets the list of accessible servers.")]
public class GetGuildsCommand : DiscordCommandBase
{
    public override async ValueTask ExecuteAsync(IConsole console)
    {
        await base.ExecuteAsync(console);

        var cancellationToken = console.RegisterCancellationHandler();

        var guilds = (await Discord.GetUserGuildsAsync(cancellationToken))
            // Show direct messages first
            .OrderByDescending(g => g.Id == Guild.DirectMessages.Id)
            .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);
        }
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/GuideCommand.cs
================================================
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
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 ValueTask ExecuteAsync(IConsole console)
    {
        // User token
        using (console.WithForegroundColor(ConsoleColor.White))
            console.Output.WriteLine("To get the token for your personal account:");

        console.Output.WriteLine(
            " *  Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"
        );
        console.Output.WriteLine(" 1. Open Discord in your web browser and login");
        console.Output.WriteLine(" 2. Open any server or direct message channel");
        console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
        console.Output.WriteLine(" 4. Navigate to the Network tab");
        console.Output.WriteLine(" 5. Press Ctrl+R to reload");
        console.Output.WriteLine(" 6. Switch between random channels to trigger network requests");
        console.Output.WriteLine(" 7. Search for a request that starts with \"messages\"");
        console.Output.WriteLine(" 8. Select the Headers tab on the right");
        console.Output.WriteLine(" 9. Scroll down to the Request Headers section");
        console.Output.WriteLine(" 10. Copy the value of the \"authorization\" header");
        console.Output.WriteLine();

        // Bot token
        using (console.WithForegroundColor(ConsoleColor.White))
            console.Output.WriteLine("To get the token for your bot:");

        console.Output.WriteLine(
            " The token is generated during bot creation. If you lost it, generate a new one:"
        );
        console.Output.WriteLine(" 1. Go to Discord developer portal");
        console.Output.WriteLine(" 2. Open your application's settings");
        console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
        console.Output.WriteLine(" 4. Under Token click Reset Token");
        console.Output.WriteLine(" 5. Click Yes, do it! and authenticate to confirm");
        console.Output.WriteLine(
            " *  Integrations using the previous token will stop working until updated"
        );
        console.Output.WriteLine(
            " *  Your bot needs to have the Message Content Intent enabled to read messages"
        );
        console.Output.WriteLine();

        // Guild or channel ID
        using (console.WithForegroundColor(ConsoleColor.White))
            console.Output.WriteLine("To get the ID of a server or a channel:");

        console.Output.WriteLine(" 1. Open Discord");
        console.Output.WriteLine(" 2. Open Settings");
        console.Output.WriteLine(" 3. Go to Advanced section");
        console.Output.WriteLine(" 4. Enable Developer Mode");
        console.Output.WriteLine(
            " 5. Right-click on the desired server or channel and click Copy Server ID or Copy Channel ID"
        );
        console.Output.WriteLine();

        // Docs link
        using (console.WithForegroundColor(ConsoleColor.White))
        {
            console.Output.WriteLine(
                "If you have questions or issues, please refer to the documentation:"
            );
        }

        using (console.WithForegroundColor(ConsoleColor.DarkCyan))
        {
            console.Output.WriteLine(
                "https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs"
            );
        }

        return default;
    }
}


================================================
FILE: DiscordChatExporter.Cli/Commands/Shared/ThreadInclusionMode.cs
================================================
namespace DiscordChatExporter.Cli.Commands.Shared;

public enum ThreadInclusionMode
{
    None,
    Active,
    All,
}


================================================
FILE: DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj
================================================
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <ApplicationIcon>..\favicon.ico</ApplicationIcon>
    <PublishTrimmed>true</PublishTrimmed>
    <CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
  </PropertyGroup>

  <!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
  <PropertyGroup>
    <EnableTrimAnalyzer>false</EnableTrimAnalyzer>
    <EnableAotAnalyzer>false</EnableAotAnalyzer>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="CliFx" />
    <PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
    <PackageReference Include="Deorcify" PrivateAssets="all" />
    <PackageReference Include="Gress" />
    <PackageReference Include="Spectre.Console" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
  </ItemGroup>
</Project>


================================================
FILE: DiscordChatExporter.Cli/Program.cs
================================================
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;

namespace DiscordChatExporter.Cli;

public static class Program
{
    // Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
    [DynamicDependency(
        DynamicallyAccessedMemberTypes.All,
        typeof(ThreadInclusionModeBindingConverter)
    )]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
    [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
    public static async Task<int> Main(string[] args) =>
        await new CliApplicationBuilder()
            .AddCommand<ExportAllCommand>()
            .AddCommand<ExportChannelsCommand>()
            .AddCommand<ExportDirectMessagesCommand>()
            .AddCommand<ExportGuildCommand>()
            .AddCommand<GetChannelsCommand>()
            .AddCommand<GetDirectChannelsCommand>()
            .AddCommand<GetGuildsCommand>()
            .AddCommand<GuideCommand>()
            .Build()
            .RunAsync(args);
}


================================================
FILE: DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs
================================================
using System;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using Spectre.Console;

namespace DiscordChatExporter.Cli.Utils.Extensions;

internal static class ConsoleExtensions
{
    extension(IConsole console)
    {
        public IAnsiConsole CreateAnsiConsole() =>
            AnsiConsole.Create(
                new AnsiConsoleSettings
                {
                    Ansi = AnsiSupport.Detect,
                    ColorSystem = ColorSystemSupport.Detect,
                    Out = new AnsiConsoleOutput(console.Output),
                }
            );

        public Status CreateStatusTicker() =>
            console.CreateAnsiConsole().Status().AutoRefresh(true);

        public Progress CreateProgressTicker() =>
            console
                .CreateAnsiConsole()
                .Progress()
                .AutoClear(false)
                .AutoRefresh(true)
                .HideCompleted(false)
                .Columns(
                    new TaskDescriptionColumn { Alignment = Justify.Left },
                    new ProgressBarColumn(),
                    new PercentageColumn()
                );
    }

    public static async ValueTask StartTaskAsync(
        this ProgressContext context,
        string description,
        Func<ProgressTask, ValueTask> performOperationAsync
    )
    {
        // Description cannot be empty
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/1133
        var actualDescription = !string.IsNullOrWhiteSpace(description) ? description : "...";

        var progressTask = context.AddTask(
            actualDescription,
            new ProgressTaskSettings { MaxValue = 1 }
        );

        try
        {
            await performOperationAsync(progressTask);
        }
        finally
        {
            progressTask.Value = progressTask.MaxValue;
            progressTask.StopTask();
        }
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj
================================================
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
    <UserSecretsId>d1fe5ae2-2a19-404d-a36e-81ba9eada1c1</UserSecretsId>
  </PropertyGroup>

  <ItemGroup>
    <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="AngleSharp" />
    <PackageReference Include="coverlet.collector" PrivateAssets="all" />
    <PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
    <PackageReference Include="FluentAssertions" />
    <PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
    <PackageReference Include="JsonExtensions" />
    <PackageReference Include="Microsoft.Extensions.Configuration" />
    <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
    <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <PackageReference Include="xunit" />
    <PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
  </ItemGroup>
</Project>


================================================
FILE: DiscordChatExporter.Cli.Tests/Infra/ChannelIds.cs
================================================
using DiscordChatExporter.Core.Discord;

namespace DiscordChatExporter.Cli.Tests.Infra;

public static class ChannelIds
{
    public static Snowflake AttachmentTestCases { get; } = Snowflake.Parse("885587741654536192");

    public static Snowflake DateRangeTestCases { get; } = Snowflake.Parse("866674248747319326");

    public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");

    public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636");

    public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");

    public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");

    public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");

    public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");

    public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");

    public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");

    public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");

    public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs
================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Threading.Tasks;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AsyncKeyedLock;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting;
using JsonExtensions;

namespace DiscordChatExporter.Cli.Tests.Infra;

public static class ExportWrapper
{
    private static readonly AsyncKeyedLocker<string> Locker = new();

    private static readonly string DirPath = Path.Combine(
        Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
            ?? Directory.GetCurrentDirectory(),
        "ExportCache"
    );

    static ExportWrapper()
    {
        try
        {
            Directory.Delete(DirPath, true);
        }
        catch (DirectoryNotFoundException) { }

        Directory.CreateDirectory(DirPath);
    }

    private static async ValueTask<string> ExportAsync(Snowflake channelId, ExportFormat format)
    {
        var fileName = channelId.ToString() + '.' + format.GetFileExtension();
        var filePath = Path.Combine(DirPath, fileName);

        using var _ = await Locker.LockAsync(filePath);
        using var console = new FakeConsole();

        // Perform the export only if it hasn't been done before
        if (!File.Exists(filePath))
        {
            await new ExportChannelsCommand
            {
                Token = Secrets.DiscordToken,
                ChannelIds = [channelId],
                ExportFormat = format,
                OutputPath = filePath,
                Locale = "en-US",
                IsUtcNormalizationEnabled = true,
            }.ExecuteAsync(console);
        }

        return await File.ReadAllTextAsync(filePath);
    }

    public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) =>
        Html.Parse(await ExportAsync(channelId, ExportFormat.HtmlDark));

    public static async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId) =>
        Json.Parse(await ExportAsync(channelId, ExportFormat.Json));

    public static async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId) =>
        await ExportAsync(channelId, ExportFormat.PlainText);

    public static async ValueTask<string> ExportAsCsvAsync(Snowflake channelId) =>
        await ExportAsync(channelId, ExportFormat.Csv);

    public static async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(
        Snowflake channelId
    ) => (await ExportAsHtmlAsync(channelId)).QuerySelectorAll("[data-message-id]").ToArray();

    public static async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(
        Snowflake channelId
    ) => (await ExportAsJsonAsync(channelId)).GetProperty("messages").EnumerateArray().ToArray();

    public static async ValueTask<IElement> GetMessageAsHtmlAsync(
        Snowflake channelId,
        Snowflake messageId
    )
    {
        var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
            string.Equals(
                e.GetAttribute("data-message-id"),
                messageId.ToString(),
                StringComparison.OrdinalIgnoreCase
            )
        );

        if (message is null)
        {
            throw new InvalidOperationException(
                $"Message #{messageId} not found in the export of channel #{channelId}."
            );
        }

        return message;
    }

    public static async ValueTask<JsonElement> GetMessageAsJsonAsync(
        Snowflake channelId,
        Snowflake messageId
    )
    {
        var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
            string.Equals(
                j.GetProperty("id").GetString(),
                messageId.ToString(),
                StringComparison.OrdinalIgnoreCase
            )
        );

        if (message.ValueKind == JsonValueKind.Undefined)
        {
            throw new InvalidOperationException(
                $"Message #{messageId} not found in the export of channel #{channelId}."
            );
        }

        return message;
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Infra/Secrets.cs
================================================
using System;
using System.Reflection;
using Microsoft.Extensions.Configuration;

namespace DiscordChatExporter.Cli.Tests.Infra;

internal static class Secrets
{
    private static readonly IConfigurationRoot Configuration = new ConfigurationBuilder()
        .AddUserSecrets(Assembly.GetExecutingAssembly())
        .AddEnvironmentVariables()
        .Build();

    public static string DiscordToken =>
        Configuration["DISCORD_TOKEN"]
        ?? throw new InvalidOperationException("Discord token not provided for tests.");
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Readme.md
================================================
# DiscordChatExporter Tests

This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios.
In order to run these tests locally, you need to join the test server and configure your authentication token.

1. [Join the test server](https://discord.gg/eRV8Vap5bm)
2. Locate your Discord authentication token
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
4. Run the tests: `dotnet test`

> [!NOTE]
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.

================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/CsvContentSpecs.cs
================================================
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class CsvContentSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_in_the_CSV_format()
    {
        // Act
        var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);

        // Assert
        document
            .Should()
            .ContainAll(
                "tyrrrz",
                "Hello world",
                "Goodbye world",
                "Foo bar",
                "Hurdle Durdle",
                "One",
                "Two",
                "Three",
                "Yeet"
            );
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
================================================
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using JsonExtensions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class DateRangeSpecs
{
    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_sent_after_the_specified_date()
    {
        // Arrange
        var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.DateRangeTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            After = Snowflake.FromDate(after),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
            .ToArray();

        timestamps.All(t => t > after).Should().BeTrue();

        timestamps
            .Should()
            .BeEquivalentTo(
                [
                    new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
                    new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
                ],
                o =>
                    o.Using<DateTimeOffset>(ctx =>
                            ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
                        )
                        .WhenTypeIs<DateTimeOffset>()
            );
    }

    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date()
    {
        // Arrange
        var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.DateRangeTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            Before = Snowflake.FromDate(before),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
            .ToArray();

        timestamps.All(t => t < before).Should().BeTrue();

        timestamps
            .Should()
            .BeEquivalentTo(
                [
                    new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero),
                ],
                o =>
                    o.Using<DateTimeOffset>(ctx =>
                            ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
                        )
                        .WhenTypeIs<DateTimeOffset>()
            );
    }

    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_sent_between_the_specified_dates()
    {
        // Arrange
        var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
        var before = new DateTimeOffset(2021, 08, 01, 0, 0, 0, TimeSpan.Zero);
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.DateRangeTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            Before = Snowflake.FromDate(before),
            After = Snowflake.FromDate(after),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
            .ToArray();

        timestamps.All(t => t < before && t > after).Should().BeTrue();

        timestamps
            .Should()
            .BeEquivalentTo(
                [
                    new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
                    new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
                ],
                o =>
                    o.Using<DateTimeOffset>(ctx =>
                            ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
                        )
                        .WhenTypeIs<DateTimeOffset>()
            );
    }

    [Fact]
    public async Task I_can_filter_the_export_to_not_include_any_messages()
    {
        // Arrange
        var before = new DateTimeOffset(2020, 08, 01, 0, 0, 0, TimeSpan.Zero);
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.DateRangeTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            Before = Snowflake.FromDate(before),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
            .ToArray();

        timestamps.Should().BeEmpty();
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/FilterSpecs.cs
================================================
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using FluentAssertions;
using JsonExtensions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class FilterSpecs
{
    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_text()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.FilterTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            MessageFilter = MessageFilter.Parse("some text"),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("content").GetString())
            .Should()
            .AllSatisfy(c => c.Contains("Some random text", StringComparison.Ordinal));
    }

    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_that_were_sent_by_the_specified_author()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.FilterTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            MessageFilter = MessageFilter.Parse("from:Tyrrrz"),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("author").GetProperty("name").GetString())
            .Should()
            .AllBe("tyrrrz");
    }

    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_that_contain_images()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.FilterTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            MessageFilter = MessageFilter.Parse("has:image"),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("content").GetString())
            .Should()
            .AllSatisfy(c => c.Contains("This has image", StringComparison.Ordinal));
    }

    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_that_have_been_pinned()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.FilterTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            MessageFilter = MessageFilter.Parse("has:pin"),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("content").GetString())
            .Should()
            .AllSatisfy(c => c.Contains("This is pinned", StringComparison.Ordinal));
    }

    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_that_contain_guild_invites()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.FilterTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            MessageFilter = MessageFilter.Parse("has:invite"),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("content").GetString())
            .Should()
            .AllSatisfy(c => c.Contains("This has invite", StringComparison.Ordinal));
    }

    [Fact]
    public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_mention()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.FilterTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            MessageFilter = MessageFilter.Parse("mentions:Tyrrrz"),
        }.ExecuteAsync(new FakeConsole());

        // Assert
        Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .Select(j => j.GetProperty("content").GetString())
            .Should()
            .AllSatisfy(c => c.Contains("This has mention", StringComparison.Ordinal));
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlAttachmentSpecs.cs
================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlAttachmentSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885587844989612074")
        );

        // Assert
        message.Text().Should().ContainAll("Generic file attachment", "Test.txt", "11 bytes");

        message
            .QuerySelectorAll("a")
            .Select(e => e.GetAttribute("href"))
            .Should()
            .Contain(u => u.Contains("Test.txt", StringComparison.Ordinal));
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_attachment()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885654862656843786")
        );

        // Assert
        message.Text().Should().Contain("Image attachment");

        message
            .QuerySelectorAll("img")
            .Select(e => e.GetAttribute("src"))
            .Should()
            .Contain(u => u.Contains("bird-thumbnail.png", StringComparison.Ordinal));
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_attachment()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/333

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885655761919836171")
        );

        // Assert
        message.Text().Should().Contain("Video attachment");

        var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
        videoUrl
            .Should()
            .StartWith(
                "https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
            );
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/333

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885656175620808734")
        );

        // Assert
        message.Text().Should().Contain("Audio attachment");

        var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
        audioUrl
            .Should()
            .StartWith(
                "https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
            );
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlContentSpecs.cs
================================================
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlContentSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_in_the_HTML_format()
    {
        // Act
        var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);

        // Assert
        messages
            .Select(e => e.GetAttribute("data-message-id"))
            .Should()
            .Equal(
                "866674314627121232",
                "866710679758045195",
                "866732113319428096",
                "868490009366396958",
                "868505966528835604",
                "868505969821364245",
                "868505973294268457",
                "885169254029213696"
            );

        messages
            .SelectMany(e => e.Text())
            .Should()
            .ContainInOrder(
                "Hello world",
                "Goodbye world",
                "Foo bar",
                "Hurdle Durdle",
                "One",
                "Two",
                "Three",
                "Yeet"
            );
    }

    [Fact]
    public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.DateRangeTestCases],
            ExportFormat = ExportFormat.HtmlDark,
            OutputPath = file.Path,
            Locale = "en-US",
            IsUtcNormalizationEnabled = true,
            IsReverseMessageOrder = true,
        }.ExecuteAsync(new FakeConsole());

        var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
        var messages = document.QuerySelectorAll("[data-message-id]").ToArray();

        // Assert
        messages
            .Select(e => e.GetAttribute("data-message-id"))
            .Should()
            .Equal(
                "885169254029213696",
                "868505973294268457",
                "868505969821364245",
                "868505966528835604",
                "868490009366396958",
                "866732113319428096",
                "866710679758045195",
                "866674314627121232"
            );
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs
================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlEmbedSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_rich_embed()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("866769910729146400")
        );

        // Assert
        message
            .Text()
            .Should()
            .ContainAll(
                "Embed author",
                "Embed title",
                "Embed description",
                "Field 1",
                "Value 1",
                "Field 2",
                "Value 2",
                "Field 3",
                "Value 3",
                "Embed footer"
            );
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_embed()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/537

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("991768701126852638")
        );

        // Assert
        message
            .QuerySelectorAll("img")
            .Select(e => e.GetAttribute("src"))
            .WhereNotNull()
            .Where(s => s.Contains("f8w05ja8s4e61.png", StringComparison.Ordinal))
            .Should()
            .ContainSingle();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_embed_and_the_text_is_hidden_if_it_only_contains_the_image_link()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/682

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("991768701126852638")
        );

        // Assert
        var content = message.QuerySelector(".chatlog__content")?.Text();
        content.Should().BeNullOrEmpty();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_embed()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("1083751036596002856")
        );

        // Assert
        message
            .QuerySelectorAll("source")
            .Select(e => e.GetAttribute("src"))
            .WhereNotNull()
            .Where(s =>
                s.Contains(
                    "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
                    StringComparison.Ordinal
                )
            )
            .Should()
            .ContainSingle();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("1019234520349814794")
        );

        // Assert
        message
            .QuerySelectorAll("source")
            .Select(e => e.GetAttribute("src"))
            .WhereNotNull()
            .Where(s => s.Contains("tooncasm-test-copy.mp4", StringComparison.Ordinal))
            .Should()
            .ContainSingle();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed_and_the_text_is_hidden_if_it_only_contains_the_video_link()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("1019234520349814794")
        );

        // Assert
        var content = message.QuerySelector(".chatlog__content")?.Text();
        content.Should().BeNullOrEmpty();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_Spotify_track_embed()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/657

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("867886632203976775")
        );

        // Assert
        var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
        iframeUrl.Should().StartWith("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
    }

    [Fact(Skip = "Twitch does not allow embeds from inside local HTML files")]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitch_clip_embed()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/1196

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("1207002986128216074")
        );

        // Assert
        var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
        iframeUrl
            .Should()
            .StartWith(
                "https://clips.twitch.tv/embed?clip=SpicyMildCiderThisIsSparta--PQhbllrvej_Ee7v"
            );
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/570

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("866472508588294165")
        );

        // Assert
        // Check that the YouTube video thumbnail image exists with the correct video ID
        var youtubeThumbnailSrc = message
            .QuerySelectorAll("img")
            .Select(e => e.GetAttribute("src"))
            .WhereNotNull()
            .FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));

        youtubeThumbnailSrc.Should().NotBeNull();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitter_post_embed_that_includes_multiple_images()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/695

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("991757444017557665")
        );

        // Assert
        var imageUrls = message
            .QuerySelectorAll("img")
            .Select(e => e.GetAttribute("src"))
            .ToArray();

        imageUrls
            .Should()
            .Contain(u =>
                u.EndsWith(
                    "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
                    StringComparison.Ordinal
                )
            );

        imageUrls
            .Should()
            .Contain(u =>
                u.EndsWith(
                    "https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
                    StringComparison.Ordinal
                )
            );

        imageUrls
            .Should()
            .Contain(u =>
                u.EndsWith(
                    "https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
                    StringComparison.Ordinal
                )
            );

        imageUrls
            .Should()
            .Contain(u =>
                u.EndsWith(
                    "https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
                    StringComparison.Ordinal
                )
            );

        message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_guild_invite()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/649

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("1075116548966064128")
        );

        // Assert
        message.Text().Should().Contain("DiscordChatExporter Test Server");
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlForwardSpecs.cs
================================================
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlForwardSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.ForwardTestCases,
            Snowflake.Parse("1455202427115536514")
        );

        // Assert
        message
            .Text()
            .ReplaceWhiteSpace()
            .Should()
            .ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlGroupingSpecs.cs
================================================
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlGroupingSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_and_the_messages_are_grouped_according_to_their_author_and_timestamps()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/152

        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.GroupingTestCases],
            ExportFormat = ExportFormat.HtmlDark,
            OutputPath = file.Path,
        }.ExecuteAsync(new FakeConsole());

        // Assert
        var messageGroups = Html.Parse(await File.ReadAllTextAsync(file.Path))
            .QuerySelectorAll(".chatlog__message-group");

        messageGroups.Should().HaveCount(2);

        messageGroups[0]
            .QuerySelectorAll(".chatlog__content")
            .Select(e => e.Text())
            .Should()
            .ContainInOrder(
                "First",
                "Second",
                "Third",
                "Fourth",
                "Fifth",
                "Sixth",
                "Seventh",
                "Eighth",
                "Ninth",
                "Tenth"
            );

        messageGroups[1]
            .QuerySelectorAll(".chatlog__content")
            .Select(e => e.Text())
            .Should()
            .ContainInOrder("Eleventh", "Twelveth", "Thirteenth", "Fourteenth", "Fifteenth");
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlMarkdownSpecs.cs
================================================
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlMarkdownSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323136411078787")
        );

        // Assert
        message
            .Text()
            .ReplaceWhiteSpace()
            .Should()
            .Contain("Default timestamp: 2/12/2023 1:36 PM");

        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_format()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323205268967596")
        );

        // Assert
        message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_format()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323235342139483")
        );

        // Assert
        message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_date_format()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323326727634984")
        );

        // Assert
        message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_date_format()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323350731640863")
        );

        // Assert
        message
            .Text()
            .ReplaceWhiteSpace()
            .Should()
            .Contain("Long date timestamp: Sunday, February 12, 2023");

        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_format()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323374379118593")
        );

        // Assert
        message
            .Text()
            .ReplaceWhiteSpace()
            .Should()
            .Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");

        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_long_format()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323409095376947")
        );

        // Assert
        message
            .Text()
            .ReplaceWhiteSpace()
            .Should()
            .Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");

        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_relative_format()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074323436853285004")
        );

        // Assert
        message
            .Text()
            .ReplaceWhiteSpace()
            .Should()
            .Contain("Relative timestamp: 2/12/2023 1:36 PM");

        message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_an_invalid_timestamp_marker()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MarkdownTestCases,
            Snowflake.Parse("1074328534409019563")
        );

        // Assert
        message.Text().Should().Contain("Invalid timestamp: Invalid date");
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs
================================================
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlMentionSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_user_mention()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MentionTestCases,
            Snowflake.Parse("866458840245076028")
        );

        // Assert
        message.Text().Should().Contain("User mention: @Tyrrrz");
        message.InnerHtml.Should().Contain("tyrrrz");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MentionTestCases,
            Snowflake.Parse("866459040480624680")
        );

        // Assert
        message.Text().Should().Contain("Text channel mention: #mention-tests");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MentionTestCases,
            Snowflake.Parse("866459175462633503")
        );

        // Assert
        message.Text().Should().Contain("Voice channel mention: 🔊general");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_role_mention()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MentionTestCases,
            Snowflake.Parse("866459254693429258")
        );

        // Assert
        message.Text().Should().Contain("Role mention: @Role 1");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.MentionTestCases,
            Snowflake.Parse("1474874276828938290")
        );

        // Assert
        message.Text().Should().Contain("Thread mention: #Thread starting message");
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs
================================================
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlReplySpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_another_message()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.ReplyTestCases,
            Snowflake.Parse("866460738239725598")
        );

        // Assert
        message.Text().Should().Contain("reply to original");
        message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain("original");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_a_deleted_message()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/645

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.ReplyTestCases,
            Snowflake.Parse("866460975388819486")
        );

        // Assert
        message.Text().Should().Contain("reply to deleted");
        message
            .QuerySelector(".chatlog__reply-link")
            ?.Text()
            .Should()
            .Contain("Original message was deleted or could not be loaded.");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_empty_message_with_an_attachment()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/634

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.ReplyTestCases,
            Snowflake.Parse("866462470335627294")
        );

        // Assert
        message.Text().Should().Contain("reply to attachment");
        message
            .QuerySelector(".chatlog__reply-link")
            ?.Text()
            .Should()
            .Contain("Click to see attachment");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_interaction()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/569

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.ReplyTestCases,
            Snowflake.Parse("1075152916417085492")
        );

        // Assert
        message.Text().Should().Contain("used /poll");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_cross_posted_from_another_guild()
    {
        // https://github.com/Tyrrrz/DiscordChatExporter/issues/633

        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.ReplyTestCases,
            Snowflake.Parse("1072165330853576876")
        );

        // Assert
        message
            .Text()
            .Should()
            .Contain("This is a test message from an announcement channel on another server");
        message.Text().Should().Contain("SERVER");
        message.QuerySelector(".chatlog__reply-link").Should().BeNull();
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlStickerSpecs.cs
================================================
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class HtmlStickerSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.StickerTestCases,
            Snowflake.Parse("939670623158943754")
        );

        // Assert
        var stickerUrl = message.QuerySelector("[title='rock'] img")?.GetAttribute("src");
        stickerUrl.Should().StartWith("https://cdn.discordapp.com/stickers/904215665597120572.png");
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsHtmlAsync(
            ChannelIds.StickerTestCases,
            Snowflake.Parse("939670526517997590")
        );

        // Assert
        var stickerUrl = message
            .QuerySelector("[title='Yikes'] [data-source]")
            ?.GetAttribute("data-source");

        stickerUrl
            .Should()
            .StartWith("https://cdn.discordapp.com/stickers/816087132447178774.json");
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/JsonAttachmentSpecs.cs
================================================
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class JsonAttachmentSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsJsonAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885587844989612074")
        );

        // Assert
        message.GetProperty("content").GetString().Should().Be("Generic file attachment");

        var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
        attachments.Should().HaveCount(1);

        attachments[0]
            .GetProperty("url")
            .GetString()
            .Should()
            .StartWith(
                "https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
            );

        attachments[0].GetProperty("fileName").GetString().Should().Be("Test.txt");
        attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_attachment()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsJsonAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885654862656843786")
        );

        // Assert
        message.GetProperty("content").GetString().Should().Be("Image attachment");

        var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
        attachments.Should().HaveCount(1);

        attachments[0]
            .GetProperty("url")
            .GetString()
            .Should()
            .StartWith(
                "https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
            );

        attachments[0].GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
        attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_attachment()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsJsonAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885655761919836171")
        );

        // Assert
        message.GetProperty("content").GetString().Should().Be("Video attachment");

        var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
        attachments.Should().HaveCount(1);

        attachments[0]
            .GetProperty("url")
            .GetString()
            .Should()
            .StartWith(
                "https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
            );

        attachments[0]
            .GetProperty("fileName")
            .GetString()
            .Should()
            .Be("file_example_MP4_640_3MG.mp4");

        attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsJsonAsync(
            ChannelIds.AttachmentTestCases,
            Snowflake.Parse("885656175620808734")
        );

        // Assert
        message.GetProperty("content").GetString().Should().Be("Audio attachment");

        var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
        attachments.Should().HaveCount(1);

        attachments[0]
            .GetProperty("url")
            .GetString()
            .Should()
            .StartWith(
                "https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
            );

        attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
        attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs
================================================
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using JsonExtensions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class JsonContentSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_in_the_JSON_format()
    {
        // Act
        var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);

        // Assert
        messages
            .Select(j => j.GetProperty("id").GetString())
            .Should()
            .Equal(
                "866674314627121232",
                "866710679758045195",
                "866732113319428096",
                "868490009366396958",
                "868505966528835604",
                "868505969821364245",
                "868505973294268457",
                "885169254029213696"
            );

        messages
            .Select(j => j.GetProperty("content").GetString())
            .Should()
            .Equal(
                "Hello world",
                "Goodbye world",
                "Foo bar",
                "Hurdle Durdle",
                "One",
                "Two",
                "Three",
                "Yeet"
            );
    }

    [Fact]
    public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
    {
        // Arrange
        using var file = TempFile.Create();

        // Act
        await new ExportChannelsCommand
        {
            Token = Secrets.DiscordToken,
            ChannelIds = [ChannelIds.DateRangeTestCases],
            ExportFormat = ExportFormat.Json,
            OutputPath = file.Path,
            Locale = "en-US",
            IsUtcNormalizationEnabled = true,
            IsReverseMessageOrder = true,
        }.ExecuteAsync(new FakeConsole());

        var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
            .GetProperty("messages")
            .EnumerateArray()
            .ToArray();

        // Assert
        messages
            .Select(j => j.GetProperty("id").GetString())
            .Should()
            .Equal(
                "885169254029213696",
                "868505973294268457",
                "868505969821364245",
                "868505966528835604",
                "868490009366396958",
                "866732113319428096",
                "866710679758045195",
                "866674314627121232"
            );
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/JsonEmbedSpecs.cs
================================================
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class JsonEmbedSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_a_rich_embed()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsJsonAsync(
            ChannelIds.EmbedTestCases,
            Snowflake.Parse("866769910729146400")
        );

        // Assert
        var embed = message.GetProperty("embeds").EnumerateArray().Single();
        embed.GetProperty("title").GetString().Should().Be("Embed title");
        embed.GetProperty("url").GetString().Should().Be("https://example.com");
        embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00");
        embed.GetProperty("description").GetString().Should().Be("**Embed** _description_");
        embed.GetProperty("color").GetString().Should().Be("#58B9FF");

        var embedAuthor = embed.GetProperty("author");
        embedAuthor.GetProperty("name").GetString().Should().Be("Embed author");
        embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author");
        embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();

        var embedThumbnail = embed.GetProperty("thumbnail");
        embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace();
        embedThumbnail.GetProperty("width").GetInt32().Should().Be(120);
        embedThumbnail.GetProperty("height").GetInt32().Should().Be(120);

        var embedFooter = embed.GetProperty("footer");
        embedFooter.GetProperty("text").GetString().Should().Be("Embed footer");
        embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace();

        var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray();
        embedFields.Should().HaveCount(3);
        embedFields[0].GetProperty("name").GetString().Should().Be("Field 1");
        embedFields[0].GetProperty("value").GetString().Should().Be("Value 1");
        embedFields[0].GetProperty("isInline").GetBoolean().Should().BeTrue();
        embedFields[1].GetProperty("name").GetString().Should().Be("Field 2");
        embedFields[1].GetProperty("value").GetString().Should().Be("Value 2");
        embedFields[1].GetProperty("isInline").GetBoolean().Should().BeTrue();
        embedFields[2].GetProperty("name").GetString().Should().Be("Field 3");
        embedFields[2].GetProperty("value").GetString().Should().Be("Value 3");
        embedFields[2].GetProperty("isInline").GetBoolean().Should().BeTrue();
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/JsonEmojiSpecs.cs
================================================
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class JsonEmojiSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsJsonAsync(
            ChannelIds.EmojiTestCases,
            Snowflake.Parse("866768521052553216")
        );

        // Assert
        var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
        inlineEmojis.Should().HaveCount(4);

        inlineEmojis[0].GetProperty("id").GetString().Should().BeNullOrEmpty();
        inlineEmojis[0].GetProperty("name").GetString().Should().Be("🙂");
        inlineEmojis[0].GetProperty("code").GetString().Should().Be("slight_smile");
        inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
        inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();

        inlineEmojis[1].GetProperty("id").GetString().Should().BeNullOrEmpty();
        inlineEmojis[1].GetProperty("name").GetString().Should().Be("😦");
        inlineEmojis[1].GetProperty("code").GetString().Should().Be("frowning");
        inlineEmojis[1].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
        inlineEmojis[1].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();

        inlineEmojis[2].GetProperty("id").GetString().Should().BeNullOrEmpty();
        inlineEmojis[2].GetProperty("name").GetString().Should().Be("😔");
        inlineEmojis[2].GetProperty("code").GetString().Should().Be("pensive");
        inlineEmojis[2].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
        inlineEmojis[2].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();

        inlineEmojis[3].GetProperty("id").GetString().Should().BeNullOrEmpty();
        inlineEmojis[3].GetProperty("name").GetString().Should().Be("😂");
        inlineEmojis[3].GetProperty("code").GetString().Should().Be("joy");
        inlineEmojis[3].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
        inlineEmojis[3].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
    }

    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately()
    {
        // Act
        var message = await ExportWrapper.GetMessageAsJsonAsync(
            ChannelIds.EmojiTestCases,
            Snowflake.Parse("1299804867447230594")
        );

        // Assert
        var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
        inlineEmojis.Should().HaveCount(1);

        inlineEmojis[0].GetProperty("id").GetString().Should().Be("754441880066064584");
        inlineEmojis[0].GetProperty("name").GetString().Should().Be("lemon_blush");
        inlineEmojis[0].GetProperty("code").GetString().Should().Be("lemon_blush");
        inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
        inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
    }
}


================================================
FILE: DiscordChatExporter.Cli.Tests/Specs/JsonForwardSpecs.cs
================================================
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;

namespace DiscordChatExporter.Cli.Tests.Specs;

public class JsonForwardSpecs
{
    [Fact]
    public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
    {
        // Act
        var message = awa
Download .txt
gitextract_vxgw9uzg/

├── .docs/
│   ├── Docker.md
│   ├── Getting-started.md
│   ├── Message-filters.md
│   ├── Readme.md
│   ├── Scheduling-Linux.md
│   ├── Scheduling-MacOS.md
│   ├── Scheduling-Windows.md
│   ├── Token-and-IDs.md
│   ├── Troubleshooting.md
│   ├── Using-the-CLI.md
│   └── Using-the-GUI.md
├── .github/
│   ├── ISSUE_TEMPLATE/
│   │   ├── bug-report.yml
│   │   └── config.yml
│   ├── dependabot.yml
│   └── workflows/
│       ├── docker.yml
│       └── main.yml
├── .gitignore
├── Directory.Build.props
├── Directory.Packages.props
├── DiscordChatExporter.Cli/
│   ├── Commands/
│   │   ├── Base/
│   │   │   ├── DiscordCommandBase.cs
│   │   │   └── ExportCommandBase.cs
│   │   ├── Converters/
│   │   │   ├── ThreadInclusionModeBindingConverter.cs
│   │   │   └── TruthyBooleanBindingConverter.cs
│   │   ├── ExportAllCommand.cs
│   │   ├── ExportChannelsCommand.cs
│   │   ├── ExportDirectMessagesCommand.cs
│   │   ├── ExportGuildCommand.cs
│   │   ├── GetChannelsCommand.cs
│   │   ├── GetDirectChannelsCommand.cs
│   │   ├── GetGuildsCommand.cs
│   │   ├── GuideCommand.cs
│   │   └── Shared/
│   │       └── ThreadInclusionMode.cs
│   ├── DiscordChatExporter.Cli.csproj
│   ├── Program.cs
│   └── Utils/
│       └── Extensions/
│           └── ConsoleExtensions.cs
├── DiscordChatExporter.Cli.Tests/
│   ├── DiscordChatExporter.Cli.Tests.csproj
│   ├── Infra/
│   │   ├── ChannelIds.cs
│   │   ├── ExportWrapper.cs
│   │   └── Secrets.cs
│   ├── Readme.md
│   ├── Specs/
│   │   ├── CsvContentSpecs.cs
│   │   ├── DateRangeSpecs.cs
│   │   ├── FilterSpecs.cs
│   │   ├── HtmlAttachmentSpecs.cs
│   │   ├── HtmlContentSpecs.cs
│   │   ├── HtmlEmbedSpecs.cs
│   │   ├── HtmlForwardSpecs.cs
│   │   ├── HtmlGroupingSpecs.cs
│   │   ├── HtmlMarkdownSpecs.cs
│   │   ├── HtmlMentionSpecs.cs
│   │   ├── HtmlReplySpecs.cs
│   │   ├── HtmlStickerSpecs.cs
│   │   ├── JsonAttachmentSpecs.cs
│   │   ├── JsonContentSpecs.cs
│   │   ├── JsonEmbedSpecs.cs
│   │   ├── JsonEmojiSpecs.cs
│   │   ├── JsonForwardSpecs.cs
│   │   ├── JsonMentionSpecs.cs
│   │   ├── JsonStickerSpecs.cs
│   │   ├── PartitioningSpecs.cs
│   │   ├── PlainTextContentSpecs.cs
│   │   ├── PlainTextForwardSpecs.cs
│   │   └── SelfContainedSpecs.cs
│   ├── Utils/
│   │   ├── Extensions/
│   │   │   └── StringExtensions.cs
│   │   ├── Html.cs
│   │   ├── TempDir.cs
│   │   └── TempFile.cs
│   └── xunit.runner.json
├── DiscordChatExporter.Cli.dockerfile
├── DiscordChatExporter.Core/
│   ├── Discord/
│   │   ├── Data/
│   │   │   ├── Application.cs
│   │   │   ├── ApplicationFlags.cs
│   │   │   ├── Attachment.cs
│   │   │   ├── Channel.cs
│   │   │   ├── ChannelConnection.cs
│   │   │   ├── ChannelKind.cs
│   │   │   ├── Common/
│   │   │   │   ├── FileSize.cs
│   │   │   │   ├── IHasId.cs
│   │   │   │   └── ImageCdn.cs
│   │   │   ├── Embeds/
│   │   │   │   ├── Embed.cs
│   │   │   │   ├── EmbedAuthor.cs
│   │   │   │   ├── EmbedField.cs
│   │   │   │   ├── EmbedFooter.cs
│   │   │   │   ├── EmbedImage.cs
│   │   │   │   ├── EmbedKind.cs
│   │   │   │   ├── EmbedVideo.cs
│   │   │   │   ├── SpotifyTrackEmbedProjection.cs
│   │   │   │   ├── TwitchClipEmbedProjection.cs
│   │   │   │   └── YouTubeVideoEmbedProjection.cs
│   │   │   ├── Emoji.cs
│   │   │   ├── EmojiIndex.cs
│   │   │   ├── Guild.cs
│   │   │   ├── Interaction.cs
│   │   │   ├── Invite.cs
│   │   │   ├── Member.cs
│   │   │   ├── Message.cs
│   │   │   ├── MessageFlags.cs
│   │   │   ├── MessageKind.cs
│   │   │   ├── MessageReference.cs
│   │   │   ├── MessageReferenceKind.cs
│   │   │   ├── MessageSnapshot.cs
│   │   │   ├── Reaction.cs
│   │   │   ├── Role.cs
│   │   │   ├── Sticker.cs
│   │   │   ├── StickerFormat.cs
│   │   │   └── User.cs
│   │   ├── DiscordClient.cs
│   │   ├── Dump/
│   │   │   ├── DataDump.cs
│   │   │   └── DataDumpChannel.cs
│   │   ├── RateLimitPreference.cs
│   │   ├── Snowflake.cs
│   │   └── TokenKind.cs
│   ├── DiscordChatExporter.Core.csproj
│   ├── Exceptions/
│   │   ├── ChannelEmptyException.cs
│   │   └── DiscordChatExporterException.cs
│   ├── Exporting/
│   │   ├── ChannelExporter.cs
│   │   ├── CsvMessageWriter.cs
│   │   ├── ExportAssetDownloader.cs
│   │   ├── ExportContext.cs
│   │   ├── ExportFormat.cs
│   │   ├── ExportRequest.cs
│   │   ├── Filtering/
│   │   │   ├── BinaryExpressionKind.cs
│   │   │   ├── BinaryExpressionMessageFilter.cs
│   │   │   ├── ContainsMessageFilter.cs
│   │   │   ├── FromMessageFilter.cs
│   │   │   ├── HasMessageFilter.cs
│   │   │   ├── MentionsMessageFilter.cs
│   │   │   ├── MessageContentMatchKind.cs
│   │   │   ├── MessageFilter.cs
│   │   │   ├── NegatedMessageFilter.cs
│   │   │   ├── NullMessageFilter.cs
│   │   │   ├── Parsing/
│   │   │   │   └── FilterGrammar.cs
│   │   │   └── ReactionMessageFilter.cs
│   │   ├── HtmlMarkdownVisitor.cs
│   │   ├── HtmlMessageExtensions.cs
│   │   ├── HtmlMessageWriter.cs
│   │   ├── JsonMessageWriter.cs
│   │   ├── MessageExporter.cs
│   │   ├── MessageGroupTemplate.cshtml
│   │   ├── MessageWriter.cs
│   │   ├── Partitioning/
│   │   │   ├── FileSizePartitionLimit.cs
│   │   │   ├── MessageCountPartitionLimit.cs
│   │   │   ├── NullPartitionLimit.cs
│   │   │   └── PartitionLimit.cs
│   │   ├── PlainTextMarkdownVisitor.cs
│   │   ├── PlainTextMessageExtensions.cs
│   │   ├── PlainTextMessageWriter.cs
│   │   ├── PostambleTemplate.cshtml
│   │   └── PreambleTemplate.cshtml
│   ├── Markdown/
│   │   ├── EmojiNode.cs
│   │   ├── FormattingKind.cs
│   │   ├── FormattingNode.cs
│   │   ├── HeadingNode.cs
│   │   ├── IContainerNode.cs
│   │   ├── InlineCodeBlockNode.cs
│   │   ├── LinkNode.cs
│   │   ├── ListItemNode.cs
│   │   ├── ListNode.cs
│   │   ├── MarkdownNode.cs
│   │   ├── MentionKind.cs
│   │   ├── MentionNode.cs
│   │   ├── MultiLineCodeBlockNode.cs
│   │   ├── Parsing/
│   │   │   ├── AggregateMatcher.cs
│   │   │   ├── IMatcher.cs
│   │   │   ├── MarkdownContext.cs
│   │   │   ├── MarkdownParser.cs
│   │   │   ├── MarkdownVisitor.cs
│   │   │   ├── ParsedMatch.cs
│   │   │   ├── RegexMatcher.cs
│   │   │   ├── StringMatcher.cs
│   │   │   └── StringSegment.cs
│   │   ├── TextNode.cs
│   │   └── TimestampNode.cs
│   └── Utils/
│       ├── Docker.cs
│       ├── Extensions/
│       │   ├── AsyncCollectionExtensions.cs
│       │   ├── CollectionExtensions.cs
│       │   ├── ColorExtensions.cs
│       │   ├── ExceptionExtensions.cs
│       │   ├── GenericExtensions.cs
│       │   ├── HttpExtensions.cs
│       │   ├── PathExtensions.cs
│       │   ├── StringExtensions.cs
│       │   ├── SuperpowerExtensions.cs
│       │   └── TimeSpanExtensions.cs
│       ├── Http.cs
│       ├── Url.cs
│       └── UrlBuilder.cs
├── DiscordChatExporter.Gui/
│   ├── App.axaml
│   ├── App.axaml.cs
│   ├── Converters/
│   │   ├── ChannelToHierarchicalNameStringConverter.cs
│   │   ├── ExportFormatToStringConverter.cs
│   │   ├── LocaleToDisplayNameStringConverter.cs
│   │   ├── MarkdownToInlinesConverter.cs
│   │   ├── RateLimitPreferenceToStringConverter.cs
│   │   └── SnowflakeToTimestampStringConverter.cs
│   ├── DiscordChatExporter.Gui.csproj
│   ├── Framework/
│   │   ├── DialogManager.cs
│   │   ├── DialogViewModelBase.cs
│   │   ├── SnackbarManager.cs
│   │   ├── ThemeVariant.cs
│   │   ├── UserControl.cs
│   │   ├── ViewManager.cs
│   │   ├── ViewModelBase.cs
│   │   ├── ViewModelManager.cs
│   │   └── Window.cs
│   ├── Localization/
│   │   ├── Language.cs
│   │   ├── LocalizationManager.English.cs
│   │   ├── LocalizationManager.French.cs
│   │   ├── LocalizationManager.German.cs
│   │   ├── LocalizationManager.Spanish.cs
│   │   ├── LocalizationManager.Ukrainian.cs
│   │   └── LocalizationManager.cs
│   ├── Models/
│   │   └── ThreadInclusionMode.cs
│   ├── Program.cs
│   ├── Publish-MacOSBundle.ps1
│   ├── Services/
│   │   ├── SettingsService.TokenEncryptionConverter.cs
│   │   ├── SettingsService.cs
│   │   └── UpdateService.cs
│   ├── StartOptions.cs
│   ├── Utils/
│   │   ├── Disposable.cs
│   │   ├── DisposableCollector.cs
│   │   ├── Extensions/
│   │   │   ├── AvaloniaExtensions.cs
│   │   │   ├── DisposableExtensions.cs
│   │   │   ├── EnvironmentExtensions.cs
│   │   │   ├── MarkdigExtensions.cs
│   │   │   ├── NotifyPropertyChangedExtensions.cs
│   │   │   └── ProcessExtensions.cs
│   │   ├── Internationalization.cs
│   │   └── NativeMethods.cs
│   ├── ViewModels/
│   │   ├── Components/
│   │   │   └── DashboardViewModel.cs
│   │   ├── Dialogs/
│   │   │   ├── ExportSetupViewModel.cs
│   │   │   ├── MessageBoxViewModel.cs
│   │   │   └── SettingsViewModel.cs
│   │   └── MainViewModel.cs
│   └── Views/
│       ├── Components/
│       │   ├── DashboardView.axaml
│       │   └── DashboardView.axaml.cs
│       ├── Controls/
│       │   ├── HyperLink.axaml
│       │   └── HyperLink.axaml.cs
│       ├── Dialogs/
│       │   ├── ExportSetupView.axaml
│       │   ├── ExportSetupView.axaml.cs
│       │   ├── MessageBoxView.axaml
│       │   ├── MessageBoxView.axaml.cs
│       │   ├── SettingsView.axaml
│       │   └── SettingsView.axaml.cs
│       ├── MainView.axaml
│       └── MainView.axaml.cs
├── DiscordChatExporter.sln
├── License.txt
├── NuGet.config
├── Readme.md
├── docker-entrypoint.sh
├── favicon.icns
└── global.json
Download .txt
SYMBOL INDEX (644 symbols across 207 files)

FILE: DiscordChatExporter.Cli.Tests/Infra/ChannelIds.cs
  class ChannelIds (line 5) | public static class ChannelIds

FILE: DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs
  class ExportWrapper (line 20) | public static class ExportWrapper
    method ExportWrapper (line 30) | static ExportWrapper()
    method ExportAsync (line 41) | private static async ValueTask<string> ExportAsync(Snowflake channelId...
    method ExportAsHtmlAsync (line 66) | public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowfla...
    method ExportAsJsonAsync (line 69) | public static async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake...
    method ExportAsPlainTextAsync (line 72) | public static async ValueTask<string> ExportAsPlainTextAsync(Snowflake...
    method ExportAsCsvAsync (line 75) | public static async ValueTask<string> ExportAsCsvAsync(Snowflake chann...
    method GetMessagesAsHtmlAsync (line 78) | public static async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHt...
    method GetMessagesAsJsonAsync (line 82) | public static async ValueTask<IReadOnlyList<JsonElement>> GetMessagesA...
    method GetMessageAsHtmlAsync (line 86) | public static async ValueTask<IElement> GetMessageAsHtmlAsync(
    method GetMessageAsJsonAsync (line 109) | public static async ValueTask<JsonElement> GetMessageAsJsonAsync(

FILE: DiscordChatExporter.Cli.Tests/Infra/Secrets.cs
  class Secrets (line 7) | internal static class Secrets

FILE: DiscordChatExporter.Cli.Tests/Specs/CsvContentSpecs.cs
  class CsvContentSpecs (line 8) | public class CsvContentSpecs
    method I_can_export_a_channel_in_the_CSV_format (line 10) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs
  class DateRangeSpecs (line 17) | public class DateRangeSpecs
    method I_can_filter_the_export_to_only_include_messages_sent_after_the_specified_date (line 19) | [Fact]
    method I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date (line 63) | [Fact]
    method I_can_filter_the_export_to_only_include_messages_sent_between_the_specified_dates (line 105) | [Fact]
    method I_can_filter_the_export_to_not_include_any_messages (line 150) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/FilterSpecs.cs
  class FilterSpecs (line 17) | public class FilterSpecs
    method I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_text (line 19) | [Fact]
    method I_can_filter_the_export_to_only_include_messages_that_were_sent_by_the_specified_author (line 44) | [Fact]
    method I_can_filter_the_export_to_only_include_messages_that_contain_images (line 69) | [Fact]
    method I_can_filter_the_export_to_only_include_messages_that_have_been_pinned (line 94) | [Fact]
    method I_can_filter_the_export_to_only_include_messages_that_contain_guild_invites (line 119) | [Fact]
    method I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_mention (line 144) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlAttachmentSpecs.cs
  class HtmlAttachmentSpecs (line 12) | public class HtmlAttachmentSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment (line 14) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_an_image_attachment (line 33) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_video_attachment (line 52) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment (line 74) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlContentSpecs.cs
  class HtmlContentSpecs (line 15) | public class HtmlContentSpecs
    method I_can_export_a_channel_in_the_HTML_format (line 17) | [Fact]
    method I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order (line 53) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs
  class HtmlEmbedSpecs (line 13) | public class HtmlEmbedSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_rich_embed (line 15) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_an_image_embed (line 42) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_an_image_embed_and_the_text_is_hidden_if_it_only_contains_the_image_link (line 63) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_video_embed (line 79) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed (line 103) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed_and_the_text_is_hidden_if_it_only_contains_the_video_link (line 122) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_Spotify_track_embed (line 136) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_Twitch_clip_embed (line 152) | [Fact(Skip = "Twitch does not allow embeds from inside local HTML file...
    method I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed (line 172) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_Twitter_post_embed_that_includes_multiple_images (line 194) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_guild_invite (line 250) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlForwardSpecs.cs
  class HtmlForwardSpecs (line 11) | public class HtmlForwardSpecs
    method I_can_export_a_channel_that_contains_a_forwarded_message (line 13) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlGroupingSpecs.cs
  class HtmlGroupingSpecs (line 15) | public class HtmlGroupingSpecs
    method I_can_export_a_channel_and_the_messages_are_grouped_according_to_their_author_and_timestamps (line 17) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlMarkdownSpecs.cs
  class HtmlMarkdownSpecs (line 11) | public class HtmlMarkdownSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker (line 13) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_format (line 32) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_format (line 46) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_date_format (line 60) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_date_format (line 74) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_format (line 93) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_long_format (line 112) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_relative_format (line 131) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_an_invalid_timestamp_marker (line 150) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs
  class HtmlMentionSpecs (line 10) | public class HtmlMentionSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_user_mention (line 12) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention (line 26) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention (line 39) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_role_mention (line 52) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_thread_mention (line 65) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs
  class HtmlReplySpecs (line 10) | public class HtmlReplySpecs
    method I_can_export_a_channel_that_contains_a_message_that_replies_to_another_message (line 12) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_that_replies_to_a_deleted_message (line 26) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_that_replies_to_an_empty_message_with_an_attachment (line 46) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_that_replies_to_an_interaction (line 66) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_cross_posted_from_another_guild (line 81) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/HtmlStickerSpecs.cs
  class HtmlStickerSpecs (line 9) | public class HtmlStickerSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker (line 11) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker (line 25) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/JsonAttachmentSpecs.cs
  class JsonAttachmentSpecs (line 10) | public class JsonAttachmentSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment (line 12) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_an_image_attachment (line 39) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_video_attachment (line 66) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment (line 98) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs
  class JsonContentSpecs (line 15) | public class JsonContentSpecs
    method I_can_export_a_channel_in_the_JSON_format (line 17) | [Fact]
    method I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order (line 53) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/JsonEmbedSpecs.cs
  class JsonEmbedSpecs (line 10) | public class JsonEmbedSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_rich_embed (line 12) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/JsonEmojiSpecs.cs
  class JsonEmojiSpecs (line 10) | public class JsonEmojiSpecs
    method I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately (line 12) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately (line 50) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/JsonForwardSpecs.cs
  class JsonForwardSpecs (line 9) | public class JsonForwardSpecs
    method I_can_export_a_channel_that_contains_a_forwarded_message (line 11) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/JsonMentionSpecs.cs
  class JsonMentionSpecs (line 10) | public class JsonMentionSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_user_mention (line 12) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention (line 32) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention (line 49) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_role_mention (line 66) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_thread_mention (line 79) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/JsonStickerSpecs.cs
  class JsonStickerSpecs (line 10) | public class JsonStickerSpecs
    method I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker (line 12) | [Fact]
    method I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker (line 34) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/PartitioningSpecs.cs
  class PartitioningSpecs (line 14) | public class PartitioningSpecs
    method I_can_export_a_channel_with_partitioning_based_on_message_count (line 16) | [Fact]
    method I_can_export_a_channel_with_partitioning_based_on_file_size (line 37) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/PlainTextContentSpecs.cs
  class PlainTextContentSpecs (line 8) | public class PlainTextContentSpecs
    method I_can_export_a_channel_in_the_TXT_format (line 10) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/PlainTextForwardSpecs.cs
  class PlainTextForwardSpecs (line 9) | public class PlainTextForwardSpecs
    method I_can_export_a_channel_that_contains_a_forwarded_message (line 11) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Specs/SelfContainedSpecs.cs
  class SelfContainedSpecs (line 14) | public class SelfContainedSpecs
    method I_can_export_a_channel_and_download_all_referenced_assets (line 16) | [Fact]

FILE: DiscordChatExporter.Cli.Tests/Utils/Extensions/StringExtensions.cs
  class StringExtensions (line 5) | internal static class StringExtensions
    method extension (line 7) | extension(string str)

FILE: DiscordChatExporter.Cli.Tests/Utils/Html.cs
  class Html (line 6) | internal static class Html
    method Parse (line 10) | public static IHtmlDocument Parse(string source) => Parser.ParseDocume...

FILE: DiscordChatExporter.Cli.Tests/Utils/TempDir.cs
  class TempDir (line 7) | internal partial class TempDir(string path) : IDisposable
    method Dispose (line 11) | public void Dispose()
    method Create (line 23) | public static TempDir Create()
  class TempDir (line 21) | internal partial class TempDir
    method Dispose (line 11) | public void Dispose()
    method Create (line 23) | public static TempDir Create()

FILE: DiscordChatExporter.Cli.Tests/Utils/TempFile.cs
  class TempFile (line 7) | internal partial class TempFile(string path) : IDisposable
    method Dispose (line 11) | public void Dispose()
    method Create (line 23) | public static TempFile Create()
  class TempFile (line 21) | internal partial class TempFile
    method Dispose (line 11) | public void Dispose()
    method Create (line 23) | public static TempFile Create()

FILE: DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs
  class DiscordCommandBase (line 12) | public abstract class DiscordCommandBase : ICommand
    method ExecuteAsync (line 45) | public virtual ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs
  class ExportCommandBase (line 25) | public abstract class ExportCommandBase : DiscordCommandBase
    method ExportAsync (line 152) | protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<...
    method ExecuteAsync (line 360) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/Converters/ThreadInclusionModeBindingConverter.cs
  class ThreadInclusionModeBindingConverter (line 7) | internal class ThreadInclusionModeBindingConverter : BindingConverter<Th...
    method Convert (line 9) | public override ThreadInclusionMode Convert(string? rawValue)

FILE: DiscordChatExporter.Cli/Commands/Converters/TruthyBooleanBindingConverter.cs
  class TruthyBooleanBindingConverter (line 6) | internal class TruthyBooleanBindingConverter : BindingConverter<bool>
    method Convert (line 8) | public override bool Convert(string? rawValue)

FILE: DiscordChatExporter.Cli/Commands/ExportAllCommand.cs
  class ExportAllCommand (line 16) | [Command("exportall", Description = "Exports all accessible channels.")]
    method ExecuteAsync (line 35) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs
  class ExportChannelsCommand (line 12) | [Command("export", Description = "Exports one or multiple channels.")]
    method ExecuteAsync (line 24) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs
  class ExportDirectMessagesCommand (line 10) | [Command("exportdm", Description = "Exports all direct message channels.")]
    method ExecuteAsync (line 13) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs
  class ExportGuildCommand (line 13) | [Command("exportguild", Description = "Exports all channels within the s...
    method ExecuteAsync (line 22) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs
  class GetChannelsCommand (line 14) | [Command("channels", Description = "Get the list of channels in a server...
    method ExecuteAsync (line 30) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/GetDirectChannelsCommand.cs
  class GetDirectChannelsCommand (line 12) | [Command("dm", Description = "Gets the list of all direct message channe...
    method ExecuteAsync (line 15) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs
  class GetGuildsCommand (line 12) | [Command("guilds", Description = "Gets the list of accessible servers.")]
    method ExecuteAsync (line 15) | public override async ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/GuideCommand.cs
  class GuideCommand (line 9) | [Command("guide", Description = "Explains how to obtain the token, serve...
    method ExecuteAsync (line 12) | public ValueTask ExecuteAsync(IConsole console)

FILE: DiscordChatExporter.Cli/Commands/Shared/ThreadInclusionMode.cs
  type ThreadInclusionMode (line 3) | public enum ThreadInclusionMode

FILE: DiscordChatExporter.Cli/Program.cs
  class Program (line 11) | public static class Program
    method Main (line 14) | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAl...

FILE: DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs
  class ConsoleExtensions (line 8) | internal static class ConsoleExtensions
    method extension (line 10) | extension(IConsole console)
    method StartTaskAsync (line 39) | public static async ValueTask StartTaskAsync(

FILE: DiscordChatExporter.Core/Discord/Data/Application.cs
  type Application (line 8) | public partial record Application(Snowflake Id, string Name, Application...
  type Application (line 15) | public partial record Application

FILE: DiscordChatExporter.Core/Discord/Data/ApplicationFlags.cs
  type ApplicationFlags (line 6) | [Flags]

FILE: DiscordChatExporter.Core/Discord/Data/Attachment.cs
  type Attachment (line 11) | public partial record Attachment(
  type Attachment (line 47) | public partial record Attachment

FILE: DiscordChatExporter.Core/Discord/Data/Channel.cs
  type Channel (line 11) | public partial record Channel(
  type Channel (line 62) | public partial record Channel

FILE: DiscordChatExporter.Core/Discord/Data/ChannelConnection.cs
  type ChannelConnection (line 6) | public record ChannelConnection(Channel Channel, IReadOnlyList<ChannelCo...

FILE: DiscordChatExporter.Core/Discord/Data/ChannelKind.cs
  type ChannelKind (line 4) | public enum ChannelKind

FILE: DiscordChatExporter.Core/Discord/Data/Common/FileSize.cs
  type FileSize (line 8) | public readonly partial record struct FileSize(long TotalBytes)
  type FileSize (line 50) | public partial record struct FileSize

FILE: DiscordChatExporter.Core/Discord/Data/Common/IHasId.cs
  type IHasId (line 3) | public interface IHasId

FILE: DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs
  class ImageCdn (line 8) | public static class ImageCdn
    method GetStandardEmojiUrl (line 11) | public static string GetStandardEmojiUrl(string emojiName)
    method GetCustomEmojiUrl (line 30) | public static string GetCustomEmojiUrl(Snowflake emojiId, bool isAnima...
    method GetGuildIconUrl (line 35) | public static string GetGuildIconUrl(Snowflake guildId, string iconHas...
    method GetChannelIconUrl (line 40) | public static string GetChannelIconUrl(Snowflake channelId, string ico...
    method GetUserAvatarUrl (line 45) | public static string GetUserAvatarUrl(Snowflake userId, string avatarH...
    method GetFallbackUserAvatarUrl (line 50) | public static string GetFallbackUserAvatarUrl(int index = 0) =>
    method GetMemberAvatarUrl (line 53) | public static string GetMemberAvatarUrl(
    method GetStickerUrl (line 63) | public static string GetStickerUrl(Snowflake stickerId, string format ...

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs
  type Embed (line 12) | public partial record Embed(
  type Embed (line 41) | public partial record Embed

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/EmbedAuthor.cs
  type EmbedAuthor (line 7) | public record EmbedAuthor(string? Name, string? Url, string? IconUrl, st...

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/EmbedField.cs
  type EmbedField (line 7) | public record EmbedField(string Name, string Value, bool IsInline)

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/EmbedFooter.cs
  type EmbedFooter (line 7) | public record EmbedFooter(string Text, string? IconUrl, string? IconProx...

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/EmbedImage.cs
  type EmbedImage (line 7) | public record EmbedImage(string? Url, string? ProxyUrl, int? Width, int?...

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/EmbedKind.cs
  type EmbedKind (line 4) | public enum EmbedKind

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/EmbedVideo.cs
  type EmbedVideo (line 7) | public record EmbedVideo(string? Url, string? ProxyUrl, int? Width, int?...

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/SpotifyTrackEmbedProjection.cs
  type SpotifyTrackEmbedProjection (line 5) | public partial record SpotifyTrackEmbedProjection(string TrackId)
  type SpotifyTrackEmbedProjection (line 10) | public partial record SpotifyTrackEmbedProjection

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/TwitchClipEmbedProjection.cs
  type TwitchClipEmbedProjection (line 5) | public partial record TwitchClipEmbedProjection(string ClipId)
  type TwitchClipEmbedProjection (line 10) | public partial record TwitchClipEmbedProjection

FILE: DiscordChatExporter.Core/Discord/Data/Embeds/YouTubeVideoEmbedProjection.cs
  type YouTubeVideoEmbedProjection (line 3) | public partial record YouTubeVideoEmbedProjection(string VideoId)
  type YouTubeVideoEmbedProjection (line 11) | public partial record YouTubeVideoEmbedProjection

FILE: DiscordChatExporter.Core/Discord/Data/Emoji.cs
  type Emoji (line 10) | public partial record Emoji(
  type Emoji (line 29) | public partial record Emoji

FILE: DiscordChatExporter.Core/Discord/Data/EmojiIndex.cs
  class EmojiIndex (line 8) | [ExcludeFromCodeCoverage]
    method GetAllNames (line 8858) | public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
    method TryGetCode (line 8860) | public static string? TryGetCode(string name) => _toCodes.GetValueOrDe...
    method TryGetName (line 8862) | public static string? TryGetName(string code) => _fromCodes.GetValueOr...

FILE: DiscordChatExporter.Core/Discord/Data/Guild.cs
  type Guild (line 9) | public partial record Guild(Snowflake Id, string Name, string IconUrl) :...
  type Guild (line 14) | public partial record Guild

FILE: DiscordChatExporter.Core/Discord/Data/Interaction.cs
  type Interaction (line 8) | public record Interaction(Snowflake Id, string Name, User User)

FILE: DiscordChatExporter.Core/Discord/Data/Invite.cs
  type Invite (line 9) | public record Invite(string Code, Guild Guild, Channel? Channel)

FILE: DiscordChatExporter.Core/Discord/Data/Member.cs
  type Member (line 11) | public partial record Member(
  type Member (line 21) | public partial record Member

FILE: DiscordChatExporter.Core/Discord/Data/Message.cs
  type Message (line 13) | public partial record Message(
  type Message (line 65) | public partial record Message

FILE: DiscordChatExporter.Core/Discord/Data/MessageFlags.cs
  type MessageFlags (line 6) | [Flags]

FILE: DiscordChatExporter.Core/Discord/Data/MessageKind.cs
  type MessageKind (line 4) | public enum MessageKind

FILE: DiscordChatExporter.Core/Discord/Data/MessageReference.cs
  type MessageReference (line 8) | public record MessageReference(

FILE: DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs
  type MessageReferenceKind (line 4) | public enum MessageReferenceKind

FILE: DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs
  type MessageSnapshot (line 11) | public record MessageSnapshot(

FILE: DiscordChatExporter.Core/Discord/Data/Reaction.cs
  type Reaction (line 7) | public record Reaction(Emoji Emoji, int Count)

FILE: DiscordChatExporter.Core/Discord/Data/Role.cs
  type Role (line 10) | public record Role(Snowflake Id, string Name, int Position, Color? Color...

FILE: DiscordChatExporter.Core/Discord/Data/Sticker.cs
  type Sticker (line 10) | public partial record Sticker(Snowflake Id, string Name, StickerFormat F...
  type Sticker (line 15) | public partial record Sticker

FILE: DiscordChatExporter.Core/Discord/Data/StickerFormat.cs
  type StickerFormat (line 3) | public enum StickerFormat

FILE: DiscordChatExporter.Core/Discord/Data/User.cs
  type User (line 9) | public partial record User(
  type User (line 30) | public partial record User

FILE: DiscordChatExporter.Core/Discord/DiscordClient.cs
  class DiscordClient (line 21) | public class DiscordClient(
    method GetResponseAsync (line 29) | private async ValueTask<HttpResponseMessage> GetResponseAsync(
    method ResolveTokenKindAsync (line 96) | private async ValueTask<TokenKind> ResolveTokenKindAsync(
    method GetResponseAsync (line 126) | private async ValueTask<HttpResponseMessage> GetResponseAsync(
    method GetJsonResponseAsync (line 136) | private async ValueTask<JsonElement> GetJsonResponseAsync(
    method TryGetJsonResponseAsync (line 178) | private async ValueTask<JsonElement?> TryGetJsonResponseAsync(
    method GetApplicationAsync (line 189) | public async ValueTask<Application> GetApplicationAsync(
    method EnsureMessageContentIntentAsync (line 197) | private async ValueTask EnsureMessageContentIntentAsync(
    method TryGetUserAsync (line 214) | public async ValueTask<User?> TryGetUserAsync(
    method GetUserGuildsAsync (line 223) | public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
    method GetGuildAsync (line 255) | public async ValueTask<Guild> GetGuildAsync(
    method GetGuildChannelsAsync (line 267) | public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(
    method GetGuildThreadsAsync (line 314) | public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
    method GetGuildRolesAsync (line 341) | public async IAsyncEnumerable<Role> GetGuildRolesAsync(
    method TryGetGuildMemberAsync (line 354) | public async ValueTask<Member?> TryGetGuildMemberAsync(
    method TryGetInviteAsync (line 370) | public async ValueTask<Invite?> TryGetInviteAsync(
    method GetChannelAsync (line 379) | public async ValueTask<Channel> GetChannelAsync(
    method TryGetChannelAsync (line 401) | public async ValueTask<Channel?> TryGetChannelAsync(
    method GetChannelThreadsAsync (line 427) | public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
    method TryGetFirstMessageAsync (line 598) | private async ValueTask<Message?> TryGetFirstMessageAsync(
    method TryGetLastMessageAsync (line 616) | private async ValueTask<Message?> TryGetLastMessageAsync(
    method GetMessagesAsync (line 632) | public async IAsyncEnumerable<Message> GetMessagesAsync(
    method GetMessagesInReverseAsync (line 710) | public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
    method GetMessageReactionsAsync (line 779) | public async IAsyncEnumerable<User> GetMessageReactionsAsync(

FILE: DiscordChatExporter.Core/Discord/Dump/DataDump.cs
  class DataDump (line 12) | public partial class DataDump(IReadOnlyList<DataDumpChannel> channels)
    method Parse (line 19) | public static DataDump Parse(JsonElement json)
    method LoadAsync (line 39) | public static async ValueTask<DataDump> LoadAsync(
  class DataDump (line 17) | public partial class DataDump
    method Parse (line 19) | public static DataDump Parse(JsonElement json)
    method LoadAsync (line 39) | public static async ValueTask<DataDump> LoadAsync(

FILE: DiscordChatExporter.Core/Discord/Dump/DataDumpChannel.cs
  type DataDumpChannel (line 3) | public record DataDumpChannel(Snowflake Id, string Name);

FILE: DiscordChatExporter.Core/Discord/RateLimitPreference.cs
  type RateLimitPreference (line 5) | [Flags]
  class RateLimitPreferenceExtensions (line 14) | public static class RateLimitPreferenceExtensions
    method extension (line 16) | extension(RateLimitPreference rateLimitPreference)

FILE: DiscordChatExporter.Core/Discord/Snowflake.cs
  type Snowflake (line 7) | public readonly partial record struct Snowflake(ulong Value)
  type Snowflake (line 18) | public partial record struct Snowflake
  type Snowflake (line 48) | public partial record struct Snowflake : IComparable<Snowflake>, ICompar...

FILE: DiscordChatExporter.Core/Discord/TokenKind.cs
  type TokenKind (line 3) | public enum TokenKind

FILE: DiscordChatExporter.Core/Exceptions/DiscordChatExporterException.cs
  class DiscordChatExporterException (line 5) | public class DiscordChatExporterException(

FILE: DiscordChatExporter.Core/Exporting/ChannelExporter.cs
  class ChannelExporter (line 11) | public class ChannelExporter(DiscordClient discord)
    method ExportChannelAsync (line 13) | public async ValueTask ExportChannelAsync(

FILE: DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs
  class CsvMessageWriter (line 12) | internal partial class CsvMessageWriter(Stream stream, ExportContext con...
    method FormatMarkdownAsync (line 17) | private async ValueTask<string> FormatMarkdownAsync(
    method WritePreambleAsync (line 25) | public override async ValueTask WritePreambleAsync(
    method WriteAttachmentsAsync (line 29) | private async ValueTask WriteAttachmentsAsync(
    method WriteReactionsAsync (line 48) | private async ValueTask WriteReactionsAsync(
    method WriteMessageAsync (line 71) | public override async ValueTask WriteMessageAsync(
    method DisposeAsync (line 115) | public override async ValueTask DisposeAsync()
    method CsvEncode (line 124) | private static string CsvEncode(string value)
  class CsvMessageWriter (line 122) | internal partial class CsvMessageWriter
    method FormatMarkdownAsync (line 17) | private async ValueTask<string> FormatMarkdownAsync(
    method WritePreambleAsync (line 25) | public override async ValueTask WritePreambleAsync(
    method WriteAttachmentsAsync (line 29) | private async ValueTask WriteAttachmentsAsync(
    method WriteReactionsAsync (line 48) | private async ValueTask WriteReactionsAsync(
    method WriteMessageAsync (line 71) | public override async ValueTask WriteMessageAsync(
    method DisposeAsync (line 115) | public override async ValueTask DisposeAsync()
    method CsvEncode (line 124) | private static string CsvEncode(string value)

FILE: DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs
  class ExportAssetDownloader (line 16) | internal partial class ExportAssetDownloader(string workingDirPath, bool...
    method DownloadAsync (line 23) | public async ValueTask<string> DownloadAsync(
    method NormalizeUrl (line 81) | private static string NormalizeUrl(string url)
    method GetFileNameFromUrl (line 96) | private static string GetFileNameFromUrl(string url, string urlHash)
    method GetFileNameFromUrl (line 122) | private static string GetFileNameFromUrl(string url) =>
    method GetLegacyFileNameFromUrl (line 133) | private static string GetLegacyFileNameFromUrl(string url) =>
  class ExportAssetDownloader (line 79) | internal partial class ExportAssetDownloader
    method DownloadAsync (line 23) | public async ValueTask<string> DownloadAsync(
    method NormalizeUrl (line 81) | private static string NormalizeUrl(string url)
    method GetFileNameFromUrl (line 96) | private static string GetFileNameFromUrl(string url, string urlHash)
    method GetFileNameFromUrl (line 122) | private static string GetFileNameFromUrl(string url) =>
    method GetLegacyFileNameFromUrl (line 133) | private static string GetLegacyFileNameFromUrl(string url) =>

FILE: DiscordChatExporter.Core/Exporting/ExportContext.cs
  class ExportContext (line 16) | internal class ExportContext(DiscordClient discord, ExportRequest request)
    method NormalizeDate (line 31) | public DateTimeOffset NormalizeDate(DateTimeOffset instant) =>
    method FormatDate (line 34) | public string FormatDate(DateTimeOffset instant, string format = "g") =>
    method PopulateChannelsAndRolesAsync (line 37) | public async ValueTask PopulateChannelsAndRolesAsync(
    method PopulateChannelAsync (line 55) | public async ValueTask PopulateChannelAsync(
    method PopulateMemberAsync (line 70) | private async ValueTask PopulateMemberAsync(
    method PopulateMemberAsync (line 96) | public async ValueTask PopulateMemberAsync(
    method PopulateMemberAsync (line 101) | public async ValueTask PopulateMemberAsync(
    method TryGetMember (line 106) | public Member? TryGetMember(Snowflake id) => _membersById.GetValueOrDe...
    method TryGetChannel (line 108) | public Channel? TryGetChannel(Snowflake id) => _channelsById.GetValueO...
    method TryGetRole (line 110) | public Role? TryGetRole(Snowflake id) => _rolesById.GetValueOrDefault(...
    method GetUserRoles (line 112) | public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
    method TryGetUserColor (line 120) | public Color? TryGetUserColor(Snowflake id) =>
    method ResolveAssetUrlAsync (line 123) | public async ValueTask<string> ResolveAssetUrlAsync(

FILE: DiscordChatExporter.Core/Exporting/ExportFormat.cs
  type ExportFormat (line 5) | public enum ExportFormat
  class ExportFormatExtensions (line 14) | public static class ExportFormatExtensions
    method extension (line 16) | extension(ExportFormat format)

FILE: DiscordChatExporter.Core/Exporting/ExportRequest.cs
  class ExportRequest (line 14) | public partial class ExportRequest
    method ExportRequest (line 50) | public ExportRequest(
    method GetDefaultOutputFileName (line 96) | public static string GetDefaultOutputFileName(
    method FormatPath (line 154) | private static string FormatPath(
    method GetOutputBaseFilePath (line 198) | private static string GetOutputBaseFilePath(
  class ExportRequest (line 94) | public partial class ExportRequest
    method ExportRequest (line 50) | public ExportRequest(
    method GetDefaultOutputFileName (line 96) | public static string GetDefaultOutputFileName(
    method FormatPath (line 154) | private static string FormatPath(
    method GetOutputBaseFilePath (line 198) | private static string GetOutputBaseFilePath(

FILE: DiscordChatExporter.Core/Exporting/Filtering/BinaryExpressionKind.cs
  type BinaryExpressionKind (line 3) | internal enum BinaryExpressionKind

FILE: DiscordChatExporter.Core/Exporting/Filtering/BinaryExpressionMessageFilter.cs
  class BinaryExpressionMessageFilter (line 6) | internal class BinaryExpressionMessageFilter(
    method IsMatch (line 12) | public override bool IsMatch(Message message) =>

FILE: DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs
  class ContainsMessageFilter (line 7) | internal class ContainsMessageFilter(string text) : MessageFilter
    method IsMatch (line 15) | private bool IsMatch(string? content) =>
    method IsMatch (line 23) | public override bool IsMatch(Message message) =>

FILE: DiscordChatExporter.Core/Exporting/Filtering/FromMessageFilter.cs
  class FromMessageFilter (line 6) | internal class FromMessageFilter(string value) : MessageFilter
    method IsMatch (line 8) | public override bool IsMatch(Message message) =>

FILE: DiscordChatExporter.Core/Exporting/Filtering/HasMessageFilter.cs
  class HasMessageFilter (line 8) | internal class HasMessageFilter(MessageContentMatchKind kind) : MessageF...
    method IsMatch (line 10) | public override bool IsMatch(Message message) =>

FILE: DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs
  class MentionsMessageFilter (line 7) | internal class MentionsMessageFilter(string value) : MessageFilter
    method IsMatch (line 9) | public override bool IsMatch(Message message) =>

FILE: DiscordChatExporter.Core/Exporting/Filtering/MessageContentMatchKind.cs
  type MessageContentMatchKind (line 3) | internal enum MessageContentMatchKind

FILE: DiscordChatExporter.Core/Exporting/Filtering/MessageFilter.cs
  class MessageFilter (line 7) | public abstract partial class MessageFilter
    method IsMatch (line 9) | public abstract bool IsMatch(Message message);
    method Parse (line 16) | public static MessageFilter Parse(string value) => FilterGrammar.Filte...
  class MessageFilter (line 12) | public partial class MessageFilter
    method IsMatch (line 9) | public abstract bool IsMatch(Message message);
    method Parse (line 16) | public static MessageFilter Parse(string value) => FilterGrammar.Filte...

FILE: DiscordChatExporter.Core/Exporting/Filtering/NegatedMessageFilter.cs
  class NegatedMessageFilter (line 5) | internal class NegatedMessageFilter(MessageFilter filter) : MessageFilter
    method IsMatch (line 7) | public override bool IsMatch(Message message) => !filter.IsMatch(messa...

FILE: DiscordChatExporter.Core/Exporting/Filtering/NullMessageFilter.cs
  class NullMessageFilter (line 5) | internal class NullMessageFilter : MessageFilter
    method IsMatch (line 7) | public override bool IsMatch(Message message) => true;

FILE: DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs
  class FilterGrammar (line 7) | internal static class FilterGrammar

FILE: DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs
  class ReactionMessageFilter (line 7) | internal class ReactionMessageFilter(string value) : MessageFilter
    method IsMatch (line 9) | public override bool IsMatch(Message message) =>

FILE: DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs
  class HtmlMarkdownVisitor (line 14) | internal partial class HtmlMarkdownVisitor(
    method VisitTextAsync (line 20) | protected override ValueTask VisitTextAsync(
    method VisitFormattingAsync (line 29) | protected override async ValueTask VisitFormattingAsync(
    method VisitHeadingAsync (line 88) | protected override async ValueTask VisitHeadingAsync(
    method VisitListAsync (line 106) | protected override async ValueTask VisitListAsync(
    method VisitListItemAsync (line 124) | protected override async ValueTask VisitListItemAsync(
    method VisitInlineCodeBlockAsync (line 142) | protected override ValueTask VisitInlineCodeBlockAsync(
    method VisitMultiLineCodeBlockAsync (line 159) | protected override ValueTask VisitMultiLineCodeBlockAsync(
    method VisitLinkAsync (line 180) | protected override async ValueTask VisitLinkAsync(
    method VisitEmojiAsync (line 207) | protected override async ValueTask VisitEmojiAsync(
    method VisitMentionAsync (line 227) | protected override async ValueTask VisitMentionAsync(
    method VisitTimestampAsync (line 315) | protected override ValueTask VisitTimestampAsync(
    method HtmlEncode (line 343) | private static string HtmlEncode(string text) => WebUtility.HtmlEncode...
    method FormatAsync (line 345) | public static async ValueTask<string> FormatAsync(
  class HtmlMarkdownVisitor (line 341) | internal partial class HtmlMarkdownVisitor
    method VisitTextAsync (line 20) | protected override ValueTask VisitTextAsync(
    method VisitFormattingAsync (line 29) | protected override async ValueTask VisitFormattingAsync(
    method VisitHeadingAsync (line 88) | protected override async ValueTask VisitHeadingAsync(
    method VisitListAsync (line 106) | protected override async ValueTask VisitListAsync(
    method VisitListItemAsync (line 124) | protected override async ValueTask VisitListItemAsync(
    method VisitInlineCodeBlockAsync (line 142) | protected override ValueTask VisitInlineCodeBlockAsync(
    method VisitMultiLineCodeBlockAsync (line 159) | protected override ValueTask VisitMultiLineCodeBlockAsync(
    method VisitLinkAsync (line 180) | protected override async ValueTask VisitLinkAsync(
    method VisitEmojiAsync (line 207) | protected override async ValueTask VisitEmojiAsync(
    method VisitMentionAsync (line 227) | protected override async ValueTask VisitMentionAsync(
    method VisitTimestampAsync (line 315) | protected override ValueTask VisitTimestampAsync(
    method HtmlEncode (line 343) | private static string HtmlEncode(string text) => WebUtility.HtmlEncode...
    method FormatAsync (line 345) | public static async ValueTask<string> FormatAsync(

FILE: DiscordChatExporter.Core/Exporting/HtmlMessageExtensions.cs
  class HtmlMessageExtensions (line 7) | internal static class HtmlMessageExtensions
    method extension (line 11) | extension(Message message)

FILE: DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs
  class HtmlMessageWriter (line 12) | internal class HtmlMessageWriter(Stream stream, ExportContext context, s...
    method CanJoinGroup (line 21) | private bool CanJoinGroup(Message message)
    method Minify (line 69) | private string Minify(string html) => _minifier.Minify(html, false).Mi...
    method WritePreambleAsync (line 71) | public override async ValueTask WritePreambleAsync(
    method WriteMessageGroupAsync (line 84) | private async ValueTask WriteMessageGroupAsync(
    method WriteMessageAsync (line 100) | public override async ValueTask WriteMessageAsync(
    method WritePostambleAsync (line 122) | public override async ValueTask WritePostambleAsync(
    method DisposeAsync (line 141) | public override async ValueTask DisposeAsync()

FILE: DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs
  class JsonMessageWriter (line 17) | internal class JsonMessageWriter(Stream stream, ExportContext context)
    method FormatMarkdownAsync (line 33) | private async ValueTask<string> FormatMarkdownAsync(
    method WriteUserAsync (line 41) | private async ValueTask WriteUserAsync(
    method WriteEmojiAsync (line 79) | private async ValueTask WriteEmojiAsync(
    method WriteRolesAsync (line 99) | private async ValueTask WriteRolesAsync(
    method WriteAttachmentAsync (line 122) | private async ValueTask WriteAttachmentAsync(
    method WriteEmbedAuthorAsync (line 140) | private async ValueTask WriteEmbedAuthorAsync(
    method WriteEmbedImageAsync (line 167) | private async ValueTask WriteEmbedImageAsync(
    method WriteEmbedVideoAsync (line 194) | private async ValueTask WriteEmbedVideoAsync(
    method WriteEmbedFooterAsync (line 221) | private async ValueTask WriteEmbedFooterAsync(
    method WriteEmbedFieldAsync (line 247) | private async ValueTask WriteEmbedFieldAsync(
    method WriteEmbedAsync (line 265) | private async ValueTask WriteEmbedAsync(
    method WriteStickerAsync (line 356) | private async ValueTask WriteStickerAsync(
    method WritePreambleAsync (line 374) | public override async ValueTask WritePreambleAsync(
    method WriteMessageAsync (line 432) | public override async ValueTask WriteMessageAsync(
    method WritePostambleAsync (line 629) | public override async ValueTask WritePostambleAsync(
    method DisposeAsync (line 643) | public override async ValueTask DisposeAsync()

FILE: DiscordChatExporter.Core/Exporting/MessageExporter.cs
  class MessageExporter (line 9) | internal partial class MessageExporter(ExportContext context) : IAsyncDi...
    method InitializeWriterAsync (line 16) | private async ValueTask<MessageWriter> InitializeWriterAsync(
    method UninitializeWriterAsync (line 46) | private async ValueTask UninitializeWriterAsync(CancellationToken canc...
    method ExportMessageAsync (line 63) | public async ValueTask ExportMessageAsync(
    method DisposeAsync (line 73) | public async ValueTask DisposeAsync()
    method GetPartitionFilePath (line 85) | private static string GetPartitionFilePath(string baseFilePath, int pa...
    method CreateMessageWriter (line 100) | private static MessageWriter CreateMessageWriter(
  class MessageExporter (line 83) | internal partial class MessageExporter
    method InitializeWriterAsync (line 16) | private async ValueTask<MessageWriter> InitializeWriterAsync(
    method UninitializeWriterAsync (line 46) | private async ValueTask UninitializeWriterAsync(CancellationToken canc...
    method ExportMessageAsync (line 63) | public async ValueTask ExportMessageAsync(
    method DisposeAsync (line 73) | public async ValueTask DisposeAsync()
    method GetPartitionFilePath (line 85) | private static string GetPartitionFilePath(string baseFilePath, int pa...
    method CreateMessageWriter (line 100) | private static MessageWriter CreateMessageWriter(

FILE: DiscordChatExporter.Core/Exporting/MessageWriter.cs
  class MessageWriter (line 9) | internal abstract class MessageWriter(Stream stream, ExportContext conte...
    method WritePreambleAsync (line 19) | public virtual ValueTask WritePreambleAsync(CancellationToken cancella...
    method WriteMessageAsync (line 22) | public virtual ValueTask WriteMessageAsync(
    method WritePostambleAsync (line 31) | public virtual ValueTask WritePostambleAsync(CancellationToken cancell...
    method DisposeAsync (line 34) | public virtual async ValueTask DisposeAsync() => await Stream.DisposeA...

FILE: DiscordChatExporter.Core/Exporting/Partitioning/FileSizePartitionLimit.cs
  class FileSizePartitionLimit (line 3) | internal class FileSizePartitionLimit(long limit) : PartitionLimit
    method IsReached (line 5) | public override bool IsReached(long messagesWritten, long bytesWritten...

FILE: DiscordChatExporter.Core/Exporting/Partitioning/MessageCountPartitionLimit.cs
  class MessageCountPartitionLimit (line 3) | internal class MessageCountPartitionLimit(long limit) : PartitionLimit
    method IsReached (line 5) | public override bool IsReached(long messagesWritten, long bytesWritten...

FILE: DiscordChatExporter.Core/Exporting/Partitioning/NullPartitionLimit.cs
  class NullPartitionLimit (line 3) | internal class NullPartitionLimit : PartitionLimit
    method IsReached (line 5) | public override bool IsReached(long messagesWritten, long bytesWritten...

FILE: DiscordChatExporter.Core/Exporting/Partitioning/PartitionLimit.cs
  class PartitionLimit (line 7) | public abstract partial class PartitionLimit
    method IsReached (line 9) | public abstract bool IsReached(long messagesWritten, long bytesWritten);
    method TryParseFileSizeBytes (line 16) | private static long? TryParseFileSizeBytes(string value, IFormatProvid...
    method TryParse (line 51) | public static PartitionLimit? TryParse(string value, IFormatProvider? ...
    method Parse (line 63) | public static PartitionLimit Parse(string value, IFormatProvider? form...
  class PartitionLimit (line 12) | public partial class PartitionLimit
    method IsReached (line 9) | public abstract bool IsReached(long messagesWritten, long bytesWritten);
    method TryParseFileSizeBytes (line 16) | private static long? TryParseFileSizeBytes(string value, IFormatProvid...
    method TryParse (line 51) | public static PartitionLimit? TryParse(string value, IFormatProvider? ...
    method Parse (line 63) | public static PartitionLimit Parse(string value, IFormatProvider? form...

FILE: DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs
  class PlainTextMarkdownVisitor (line 10) | internal partial class PlainTextMarkdownVisitor(ExportContext context, S...
    method VisitTextAsync (line 13) | protected override ValueTask VisitTextAsync(
    method VisitEmojiAsync (line 22) | protected override ValueTask VisitEmojiAsync(
    method VisitMentionAsync (line 32) | protected override async ValueTask VisitMentionAsync(
    method VisitTimestampAsync (line 84) | protected override ValueTask VisitTimestampAsync(
    method FormatAsync (line 101) | public static async ValueTask<string> FormatAsync(
  class PlainTextMarkdownVisitor (line 99) | internal partial class PlainTextMarkdownVisitor
    method VisitTextAsync (line 13) | protected override ValueTask VisitTextAsync(
    method VisitEmojiAsync (line 22) | protected override ValueTask VisitEmojiAsync(
    method VisitMentionAsync (line 32) | protected override async ValueTask VisitMentionAsync(
    method VisitTimestampAsync (line 84) | protected override ValueTask VisitTimestampAsync(
    method FormatAsync (line 101) | public static async ValueTask<string> FormatAsync(

FILE: DiscordChatExporter.Core/Exporting/PlainTextMessageExtensions.cs
  class PlainTextMessageExtensions (line 8) | internal static class PlainTextMessageExtensions
    method extension (line 10) | extension(Message message)

FILE: DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs
  class PlainTextMessageWriter (line 13) | internal class PlainTextMessageWriter(Stream stream, ExportContext context)
    method FormatMarkdownAsync (line 18) | private async ValueTask<string> FormatMarkdownAsync(
    method WriteMessageHeaderAsync (line 26) | private async ValueTask WriteMessageHeaderAsync(Message message)
    method WriteAttachmentsAsync (line 39) | private async ValueTask WriteAttachmentsAsync(
    method WriteEmbedsAsync (line 61) | private async ValueTask WriteEmbedsAsync(
    method WriteStickersAsync (line 145) | private async ValueTask WriteStickersAsync(
    method WriteReactionsAsync (line 167) | private async ValueTask WriteReactionsAsync(
    method WritePreambleAsync (line 197) | public override async ValueTask WritePreambleAsync(
    method WriteForwardedMessageAsync (line 228) | private async ValueTask WriteForwardedMessageAsync(
    method WriteMessageAsync (line 253) | public override async ValueTask WriteMessageAsync(
    method WritePostambleAsync (line 292) | public override async ValueTask WritePostambleAsync(
    method DisposeAsync (line 301) | public override async ValueTask DisposeAsync()

FILE: DiscordChatExporter.Core/Markdown/EmojiNode.cs
  type EmojiNode (line 6) | internal record EmojiNode(

FILE: DiscordChatExporter.Core/Markdown/FormattingKind.cs
  type FormattingKind (line 3) | internal enum FormattingKind

FILE: DiscordChatExporter.Core/Markdown/FormattingNode.cs
  type FormattingNode (line 5) | internal record FormattingNode(FormattingKind Kind, IReadOnlyList<Markdo...

FILE: DiscordChatExporter.Core/Markdown/HeadingNode.cs
  type HeadingNode (line 5) | internal record HeadingNode(int Level, IReadOnlyList<MarkdownNode> Child...

FILE: DiscordChatExporter.Core/Markdown/IContainerNode.cs
  type IContainerNode (line 5) | internal interface IContainerNode

FILE: DiscordChatExporter.Core/Markdown/InlineCodeBlockNode.cs
  type InlineCodeBlockNode (line 3) | internal record InlineCodeBlockNode(string Code) : MarkdownNode;

FILE: DiscordChatExporter.Core/Markdown/LinkNode.cs
  type LinkNode (line 6) | internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)

FILE: DiscordChatExporter.Core/Markdown/ListItemNode.cs
  type ListItemNode (line 5) | internal record ListItemNode(IReadOnlyList<MarkdownNode> Children) : Mar...

FILE: DiscordChatExporter.Core/Markdown/ListNode.cs
  type ListNode (line 5) | internal record ListNode(IReadOnlyList<ListItemNode> Items) : MarkdownNode;

FILE: DiscordChatExporter.Core/Markdown/MarkdownNode.cs
  type MarkdownNode (line 3) | internal abstract record MarkdownNode;

FILE: DiscordChatExporter.Core/Markdown/MentionKind.cs
  type MentionKind (line 3) | internal enum MentionKind

FILE: DiscordChatExporter.Core/Markdown/MentionNode.cs
  type MentionNode (line 6) | internal record MentionNode(Snowflake? TargetId, MentionKind Kind) : Mar...

FILE: DiscordChatExporter.Core/Markdown/MultiLineCodeBlockNode.cs
  type MultiLineCodeBlockNode (line 3) | internal record MultiLineCodeBlockNode(string Language, string Code) : M...

FILE: DiscordChatExporter.Core/Markdown/Parsing/AggregateMatcher.cs
  class AggregateMatcher (line 5) | internal class AggregateMatcher<TContext, TValue>(
    method TryMatch (line 9) | public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment s...

FILE: DiscordChatExporter.Core/Markdown/Parsing/IMatcher.cs
  type IMatcher (line 6) | internal interface IMatcher<in TContext, TValue>
    method TryMatch (line 8) | ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment);
  class MatcherExtensions (line 11) | internal static class MatcherExtensions
    method MatchAll (line 13) | public static IEnumerable<ParsedMatch<TValue>> MatchAll<TContext, TVal...

FILE: DiscordChatExporter.Core/Markdown/Parsing/MarkdownContext.cs
  type MarkdownContext (line 3) | internal readonly record struct MarkdownContext(int Depth = 0);

FILE: DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs
  class MarkdownParser (line 16) | internal static partial class MarkdownParser
    method Parse (line 426) | private static IReadOnlyList<MarkdownNode> Parse(
    method Extract (line 452) | private static void Extract<TNode>(
    method Extract (line 468) | public static IReadOnlyList<TNode> Extract<TNode>(string markdown)
    method ExtractLinks (line 477) | public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) =>
    method ExtractEmojis (line 480) | public static IReadOnlyList<EmojiNode> ExtractEmojis(string markdown) =>
    method Parse (line 483) | private static IReadOnlyList<MarkdownNode> Parse(
    method Parse (line 488) | public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
    method ParseMinimal (line 491) | private static IReadOnlyList<MarkdownNode> ParseMinimal(
    method ParseMinimal (line 496) | public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown...
  class MarkdownParser (line 450) | internal static partial class MarkdownParser
    method Parse (line 426) | private static IReadOnlyList<MarkdownNode> Parse(
    method Extract (line 452) | private static void Extract<TNode>(
    method Extract (line 468) | public static IReadOnlyList<TNode> Extract<TNode>(string markdown)
    method ExtractLinks (line 477) | public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) =>
    method ExtractEmojis (line 480) | public static IReadOnlyList<EmojiNode> ExtractEmojis(string markdown) =>
    method Parse (line 483) | private static IReadOnlyList<MarkdownNode> Parse(
    method Parse (line 488) | public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
    method ParseMinimal (line 491) | private static IReadOnlyList<MarkdownNode> ParseMinimal(
    method ParseMinimal (line 496) | public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown...

FILE: DiscordChatExporter.Core/Markdown/Parsing/MarkdownVisitor.cs
  class MarkdownVisitor (line 8) | internal abstract class MarkdownVisitor
    method VisitTextAsync (line 10) | protected virtual ValueTask VisitTextAsync(
    method VisitFormattingAsync (line 15) | protected virtual async ValueTask VisitFormattingAsync(
    method VisitHeadingAsync (line 20) | protected virtual async ValueTask VisitHeadingAsync(
    method VisitListAsync (line 25) | protected virtual async ValueTask VisitListAsync(
    method VisitListItemAsync (line 30) | protected virtual async ValueTask VisitListItemAsync(
    method VisitInlineCodeBlockAsync (line 35) | protected virtual ValueTask VisitInlineCodeBlockAsync(
    method VisitMultiLineCodeBlockAsync (line 40) | protected virtual ValueTask VisitMultiLineCodeBlockAsync(
    method VisitLinkAsync (line 45) | protected virtual async ValueTask VisitLinkAsync(
    method VisitEmojiAsync (line 50) | protected virtual ValueTask VisitEmojiAsync(
    method VisitMentionAsync (line 55) | protected virtual ValueTask VisitMentionAsync(
    method VisitTimestampAsync (line 60) | protected virtual ValueTask VisitTimestampAsync(
    method VisitAsync (line 65) | public async ValueTask VisitAsync(
    method VisitAsync (line 139) | public async ValueTask VisitAsync(

FILE: DiscordChatExporter.Core/Markdown/Parsing/ParsedMatch.cs
  class ParsedMatch (line 3) | internal class ParsedMatch<T>(StringSegment segment, T value)

FILE: DiscordChatExporter.Core/Markdown/Parsing/RegexMatcher.cs
  class RegexMatcher (line 6) | internal class RegexMatcher<TContext, TValue>(
    method TryMatch (line 11) | public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment s...

FILE: DiscordChatExporter.Core/Markdown/Parsing/StringMatcher.cs
  class StringMatcher (line 5) | internal class StringMatcher<TContext, TValue>(
    method StringMatcher (line 11) | public StringMatcher(string needle, Func<TContext, StringSegment, TVal...
    method TryMatch (line 14) | public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment s...

FILE: DiscordChatExporter.Core/Markdown/Parsing/StringSegment.cs
  type StringSegment (line 5) | internal readonly record struct StringSegment(string Source, int StartIn...

FILE: DiscordChatExporter.Core/Markdown/TextNode.cs
  type TextNode (line 3) | internal record TextNode(string Text) : MarkdownNode;

FILE: DiscordChatExporter.Core/Markdown/TimestampNode.cs
  type TimestampNode (line 6) | internal record TimestampNode(DateTimeOffset? Instant, string? Format) :...

FILE: DiscordChatExporter.Core/Utils/Docker.cs
  class Docker (line 5) | public static class Docker

FILE: DiscordChatExporter.Core/Utils/Extensions/AsyncCollectionExtensions.cs
  class AsyncCollectionExtensions (line 7) | public static class AsyncCollectionExtensions
    method CollectAsync (line 11) | private async ValueTask<IReadOnlyList<T>> CollectAsync()
    method GetAwaiter (line 21) | public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>

FILE: DiscordChatExporter.Core/Utils/Extensions/CollectionExtensions.cs
  class CollectionExtensions (line 5) | public static class CollectionExtensions
    method ToSingletonEnumerable (line 9) | public IEnumerable<T> ToSingletonEnumerable()

FILE: DiscordChatExporter.Core/Utils/Extensions/ColorExtensions.cs
  class ColorExtensions (line 5) | public static class ColorExtensions
    method extension (line 7) | extension(Color color)

FILE: DiscordChatExporter.Core/Utils/Extensions/ExceptionExtensions.cs
  class ExceptionExtensions (line 6) | public static class ExceptionExtensions
    method extension (line 8) | extension(Exception exception)

FILE: DiscordChatExporter.Core/Utils/Extensions/GenericExtensions.cs
  class GenericExtensions (line 6) | public static class GenericExtensions
    method Pipe (line 10) | public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);

FILE: DiscordChatExporter.Core/Utils/Extensions/HttpExtensions.cs
  class HttpExtensions (line 5) | public static class HttpExtensions
    method extension (line 7) | extension(HttpHeaders headers)

FILE: DiscordChatExporter.Core/Utils/Extensions/PathExtensions.cs
  class PathExtensions (line 7) | public static class PathExtensions
    method extension (line 28) | extension(Path)

FILE: DiscordChatExporter.Core/Utils/Extensions/StringExtensions.cs
  class StringExtensions (line 5) | public static class StringExtensions
    method extension (line 7) | extension(string str)
    method extension (line 29) | extension(StringBuilder builder)

FILE: DiscordChatExporter.Core/Utils/Extensions/SuperpowerExtensions.cs
  class SuperpowerExtensions (line 8) | public static class SuperpowerExtensions
    method Token (line 12) | public TextParser<T> Token() =>
    method Log (line 17) | [ExcludeFromCodeCoverage]

FILE: DiscordChatExporter.Core/Utils/Extensions/TimeSpanExtensions.cs
  class TimeSpanExtensions (line 5) | public static class TimeSpanExtensions
    method extension (line 7) | extension(TimeSpan value)

FILE: DiscordChatExporter.Core/Utils/Http.cs
  class Http (line 14) | public static class Http
    method IsRetryableStatusCode (line 18) | private static bool IsRetryableStatusCode(HttpStatusCode statusCode) =>
    method IsRetryableException (line 25) | private static bool IsRetryableException(Exception exception) =>

FILE: DiscordChatExporter.Core/Utils/Url.cs
  class Url (line 7) | public static class Url
    method EncodeFilePath (line 9) | public static string EncodeFilePath(string filePath)

FILE: DiscordChatExporter.Core/Utils/UrlBuilder.cs
  class UrlBuilder (line 9) | public class UrlBuilder
    method SetPath (line 17) | public UrlBuilder SetPath(string path)
    method SetQueryParameter (line 23) | public UrlBuilder SetQueryParameter(string key, string? value, bool ig...
    method Build (line 35) | public string Build()

FILE: DiscordChatExporter.Gui/App.axaml.cs
  class App (line 21) | public class App : Application, IDisposable
    method App (line 31) | public App()
    method Initialize (line 78) | public override void Initialize()
    method InitializeTheme (line 85) | private void InitializeTheme()
    method OnFrameworkInitializationCompleted (line 100) | public override void OnFrameworkInitializationCompleted()
    method Application_OnActualThemeVariantChanged (line 130) | private void Application_OnActualThemeVariantChanged(object? sender, E...
    method Dispose (line 134) | public void Dispose()

FILE: DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs
  class ChannelToHierarchicalNameStringConverter (line 8) | public class ChannelToHierarchicalNameStringConverter : IValueConverter
    method Convert (line 12) | public object? Convert(
    method ConvertBack (line 19) | public object ConvertBack(

FILE: DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs
  class ExportFormatToStringConverter (line 8) | public class ExportFormatToStringConverter : IValueConverter
    method Convert (line 12) | public object? Convert(
    method ConvertBack (line 19) | public object ConvertBack(

FILE: DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs
  class LocaleToDisplayNameStringConverter (line 7) | public class LocaleToDisplayNameStringConverter : IValueConverter
    method Convert (line 11) | public object Convert(object? value, Type targetType, object? paramete...
    method ConvertBack (line 16) | public object ConvertBack(

FILE: DiscordChatExporter.Gui/Converters/MarkdownToInlinesConverter.cs
  class MarkdownToInlinesConverter (line 16) | public class MarkdownToInlinesConverter : IValueConverter
    method ProcessInline (line 24) | private static void ProcessInline(
    method Convert (line 99) | public object? Convert(object? value, Type targetType, object? paramet...
    method ConvertBack (line 162) | public object? ConvertBack(

FILE: DiscordChatExporter.Gui/Converters/RateLimitPreferenceToStringConverter.cs
  class RateLimitPreferenceToStringConverter (line 8) | public class RateLimitPreferenceToStringConverter : IValueConverter
    method Convert (line 12) | public object? Convert(
    method ConvertBack (line 22) | public object ConvertBack(

FILE: DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs
  class SnowflakeToTimestampStringConverter (line 8) | public class SnowflakeToTimestampStringConverter : IValueConverter
    method Convert (line 12) | public object? Convert(
    method ConvertBack (line 19) | public object ConvertBack(

FILE: DiscordChatExporter.Gui/Framework/DialogManager.cs
  class DialogManager (line 14) | public class DialogManager : IDisposable
    method ShowDialogAsync (line 18) | public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
    method PromptSaveFilePathAsync (line 54) | public async Task<string?> PromptSaveFilePathAsync(
    method PromptDirectoryPathAsync (line 75) | public async Task<string?> PromptDirectoryPathAsync(string defaultDirP...
    method Dispose (line 98) | public void Dispose() => _dialogLock.Dispose();

FILE: DiscordChatExporter.Gui/Framework/DialogViewModelBase.cs
  class DialogViewModelBase (line 7) | public abstract partial class DialogViewModelBase<T> : ViewModelBase
    method Close (line 16) | [RelayCommand]
    method WaitForCloseAsync (line 23) | public async Task<T> WaitForCloseAsync() => await _closeTcs.Task;

FILE: DiscordChatExporter.Gui/Framework/SnackbarManager.cs
  class SnackbarManager (line 8) | public class SnackbarManager
    method Notify (line 12) | public void Notify(string message, TimeSpan? duration = null) =>
    method Notify (line 19) | public void Notify(

FILE: DiscordChatExporter.Gui/Framework/ThemeVariant.cs
  type ThemeVariant (line 3) | public enum ThemeVariant

FILE: DiscordChatExporter.Gui/Framework/UserControl.cs
  class UserControl (line 6) | public class UserControl<TDataContext> : UserControl

FILE: DiscordChatExporter.Gui/Framework/ViewManager.cs
  class ViewManager (line 12) | public partial class ViewManager
    method TryCreateView (line 14) | private Control? TryCreateView(ViewModelBase viewModel) =>
    method TryBindView (line 25) | public Control? TryBindView(ViewModelBase viewModel)
    method Match (line 39) | bool IDataTemplate.Match(object? data) => data is ViewModelBase;
    method Build (line 41) | Control? ITemplate<object?, Control?>.Build(object? data) =>
  class ViewManager (line 37) | public partial class ViewManager : IDataTemplate
    method TryCreateView (line 14) | private Control? TryCreateView(ViewModelBase viewModel) =>
    method TryBindView (line 25) | public Control? TryBindView(ViewModelBase viewModel)
    method Match (line 39) | bool IDataTemplate.Match(object? data) => data is ViewModelBase;
    method Build (line 41) | Control? ITemplate<object?, Control?>.Build(object? data) =>

FILE: DiscordChatExporter.Gui/Framework/ViewModelBase.cs
  class ViewModelBase (line 6) | public abstract class ViewModelBase : ObservableObject, IDisposable
    method OnAllPropertiesChanged (line 10) | protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Em...
    method Dispose (line 12) | protected virtual void Dispose(bool disposing) { }
    method Dispose (line 14) | public void Dispose()

FILE: DiscordChatExporter.Gui/Framework/ViewModelManager.cs
  class ViewModelManager (line 11) | public class ViewModelManager(IServiceProvider services)
    method CreateMainViewModel (line 13) | public MainViewModel CreateMainViewModel() => services.GetRequiredServ...
    method CreateDashboardViewModel (line 15) | public DashboardViewModel CreateDashboardViewModel() =>
    method CreateExportSetupViewModel (line 18) | public ExportSetupViewModel CreateExportSetupViewModel(
    method CreateMessageBoxViewModel (line 31) | public MessageBoxViewModel CreateMessageBoxViewModel(
    method CreateMessageBoxViewModel (line 48) | public MessageBoxViewModel CreateMessageBoxViewModel(string title, str...
    method CreateSettingsViewModel (line 51) | public SettingsViewModel CreateSettingsViewModel() =>

FILE: DiscordChatExporter.Gui/Framework/Window.cs
  class Window (line 6) | public class Window<TDataContext> : Window

FILE: DiscordChatExporter.Gui/Localization/Language.cs
  type Language (line 3) | public enum Language

FILE: DiscordChatExporter.Gui/Localization/LocalizationManager.English.cs
  class LocalizationManager (line 5) | public partial class LocalizationManager

FILE: DiscordChatExporter.Gui/Localization/LocalizationManager.French.cs
  class LocalizationManager (line 5) | public partial class LocalizationManager

FILE: DiscordChatExporter.Gui/Localization/LocalizationManager.German.cs
  class LocalizationManager (line 5) | public partial class LocalizationManager

FILE: DiscordChatExporter.Gui/Localization/LocalizationManager.Spanish.cs
  class LocalizationManager (line 5) | public partial class LocalizationManager

FILE: DiscordChatExporter.Gui/Localization/LocalizationManager.Ukrainian.cs
  class LocalizationManager (line 5) | public partial class LocalizationManager

FILE: DiscordChatExporter.Gui/Localization/LocalizationManager.cs
  class LocalizationManager (line 11) | public partial class LocalizationManager : ObservableObject, IDisposable
    method LocalizationManager (line 15) | public LocalizationManager(SettingsService settingsService)
    method Get (line 40) | private string Get([CallerMemberName] string? key = null)
    method Dispose (line 75) | public void Dispose() => _eventRoot.Dispose();
  class LocalizationManager (line 78) | public partial class LocalizationManager
    method LocalizationManager (line 15) | public LocalizationManager(SettingsService settingsService)
    method Get (line 40) | private string Get([CallerMemberName] string? key = null)
    method Dispose (line 75) | public void Dispose() => _eventRoot.Dispose();

FILE: DiscordChatExporter.Gui/Models/ThreadInclusionMode.cs
  type ThreadInclusionMode (line 3) | public enum ThreadInclusionMode

FILE: DiscordChatExporter.Gui/Program.cs
  class Program (line 8) | public static class Program
    method BuildAvaloniaApp (line 26) | public static AppBuilder BuildAvaloniaApp() =>
    method Main (line 29) | [STAThread]

FILE: DiscordChatExporter.Gui/Services/SettingsService.TokenEncryptionConverter.cs
  class SettingsService (line 10) | public partial class SettingsService
    class TokenEncryptionConverter (line 12) | private class TokenEncryptionConverter : JsonConverter<string?>
      method Read (line 26) | public override string? Read(
      method Write (line 72) | public override void Write(

FILE: DiscordChatExporter.Gui/Services/SettingsService.cs
  class SettingsService (line 12) | [ObservableObject]
    method Save (line 75) | public override void Save()
  class SettingsService (line 88) | public partial class SettingsService
    method Save (line 75) | public override void Save()

FILE: DiscordChatExporter.Gui/Services/UpdateService.cs
  class UpdateService (line 10) | public class UpdateService(SettingsService settingsService) : IDisposable
    method CheckForUpdatesAsync (line 31) | public async ValueTask<Version?> CheckForUpdatesAsync()
    method PrepareUpdateAsync (line 43) | public async ValueTask PrepareUpdateAsync(Version version)
    method FinalizeUpdate (line 66) | public void FinalizeUpdate(bool needRestart)
    method Dispose (line 92) | public void Dispose() => _updateManager?.Dispose();

FILE: DiscordChatExporter.Gui/StartOptions.cs
  class StartOptions (line 6) | public partial class StartOptions
  class StartOptions (line 11) | public partial class StartOptions

FILE: DiscordChatExporter.Gui/Utils/Disposable.cs
  class Disposable (line 5) | internal class Disposable(Action dispose) : IDisposable
    method Create (line 7) | public static IDisposable Create(Action dispose) => new Disposable(dis...
    method Dispose (line 9) | public void Dispose() => dispose();

FILE: DiscordChatExporter.Gui/Utils/DisposableCollector.cs
  class DisposableCollector (line 7) | internal class DisposableCollector : IDisposable
    method Add (line 12) | public void Add(IDisposable item)
    method Dispose (line 20) | public void Dispose()

FILE: DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs
  class AvaloniaExtensions (line 7) | internal static class AvaloniaExtensions
    method extension (line 9) | extension(IApplicationLifetime lifetime)

FILE: DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs
  class DisposableExtensions (line 7) | internal static class DisposableExtensions
    method extension (line 9) | extension(IEnumerable<IDisposable> disposables)

FILE: DiscordChatExporter.Gui/Utils/Extensions/EnvironmentExtensions.cs
  class EnvironmentExtensions (line 6) | internal static class EnvironmentExtensions
    method extension (line 8) | extension(Environment)

FILE: DiscordChatExporter.Gui/Utils/Extensions/MarkdigExtensions.cs
  class MarkdigExtensions (line 6) | internal static class MarkdigExtensions
    method extension (line 8) | extension(Inline inline)

FILE: DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs
  class NotifyPropertyChangedExtensions (line 8) | internal static class NotifyPropertyChangedExtensions
    method WatchProperty (line 13) | public IDisposable WatchProperty<TProperty>(
    method WatchAllProperties (line 42) | public IDisposable WatchAllProperties(Action callback, bool watchIniti...

FILE: DiscordChatExporter.Gui/Utils/Extensions/ProcessExtensions.cs
  class ProcessExtensions (line 5) | internal static class ProcessExtensions
    method extension (line 7) | extension(Process)

FILE: DiscordChatExporter.Gui/Utils/Internationalization.cs
  class Internationalization (line 5) | internal static class Internationalization

FILE: DiscordChatExporter.Gui/Utils/NativeMethods.cs
  class NativeMethods (line 5) | internal static class NativeMethods
    class Windows (line 7) | public static class Windows
      method MessageBox (line 9) | [DllImport("user32.dll", SetLastError = true)]

FILE: DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs
  class DashboardViewModel (line 26) | public partial class DashboardViewModel : ViewModelBase
    method DashboardViewModel (line 38) | public DashboardViewModel(
    method Initialize (line 99) | [RelayCommand]
    method ShowSettingsAsync (line 106) | [RelayCommand]
    method CanPullGuilds (line 110) | private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(...
    method PullGuildsAsync (line 112) | [RelayCommand(CanExecute = nameof(CanPullGuilds))]
    method CanPullChannels (line 159) | private bool CanPullChannels() => !IsBusy && _discord is not null && S...
    method PullChannelsAsync (line 161) | [RelayCommand(CanExecute = nameof(CanPullChannels))]
    method CanExport (line 226) | private bool CanExport() =>
    method ExportAsync (line 229) | [RelayCommand(CanExecute = nameof(CanExport))]
    method Dispose (line 331) | protected override void Dispose(bool disposing)

FILE: DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs
  class ExportSetupViewModel (line 20) | public partial class ExportSetupViewModel(
    method Initialize (line 105) | [RelayCommand]
    method ShowOutputPathPromptAsync (line 131) | [RelayCommand]
    method ShowAssetsDirPathPromptAsync (line 167) | [RelayCommand]
    method ConfirmAsync (line 175) | [RelayCommand]

FILE: DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs
  class MessageBoxViewModel (line 6) | public partial class MessageBoxViewModel : DialogViewModelBase

FILE: DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs
  class SettingsViewModel (line 14) | public class SettingsViewModel : DialogViewModelBase
    method SettingsViewModel (line 20) | public SettingsViewModel(
    method Dispose (line 136) | protected override void Dispose(bool disposing)

FILE: DiscordChatExporter.Gui/ViewModels/MainViewModel.cs
  class MainViewModel (line 14) | public partial class MainViewModel(
    method ShowUkraineSupportMessageAsync (line 27) | private async Task ShowUkraineSupportMessageAsync()
    method ShowDevelopmentBuildMessageAsync (line 47) | private async Task ShowDevelopmentBuildMessageAsync()
    method CheckForUpdatesAsync (line 67) | private async Task CheckForUpdatesAsync()
    method InitializeAsync (line 103) | [RelayCommand]
    method Dispose (line 111) | protected override void Dispose(bool disposing)

FILE: DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs
  class DashboardView (line 11) | public partial class DashboardView : UserControl<DashboardViewModel>
    method DashboardView (line 13) | public DashboardView() => InitializeComponent();
    method UserControl_OnLoaded (line 15) | private void UserControl_OnLoaded(object? sender, RoutedEventArgs args)
    method AvailableGuildsListBox_OnSelectionChanged (line 21) | private void AvailableGuildsListBox_OnSelectionChanged(
    method AvailableChannelsTreeView_OnSelectionChanged (line 26) | private void AvailableChannelsTreeView_OnSelectionChanged(
    method ChannelGrid_OnDoubleTapped (line 41) | private void ChannelGrid_OnDoubleTapped(object? sender, TappedEventArg...

FILE: DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs
  class HyperLink (line 10) | public partial class HyperLink : UserControl
    method HyperLink (line 27) | public HyperLink() => InitializeComponent();
    method TextBlock_OnPointerReleased (line 53) | private void TextBlock_OnPointerReleased(object? sender, PointerReleas...

FILE: DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml.cs
  class ExportSetupView (line 7) | public partial class ExportSetupView : UserControl<ExportSetupViewModel>
    method ExportSetupView (line 9) | public ExportSetupView() => InitializeComponent();
    method UserControl_OnLoaded (line 11) | private void UserControl_OnLoaded(object? sender, RoutedEventArgs args...

FILE: DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.axaml.cs
  class MessageBoxView (line 6) | public partial class MessageBoxView : UserControl<MessageBoxViewModel>
    method MessageBoxView (line 8) | public MessageBoxView() => InitializeComponent();

FILE: DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml.cs
  class SettingsView (line 6) | public partial class SettingsView : UserControl<SettingsViewModel>
    method SettingsView (line 8) | public SettingsView() => InitializeComponent();

FILE: DiscordChatExporter.Gui/Views/MainView.axaml.cs
  class MainView (line 7) | public partial class MainView : Window<MainViewModel>
    method MainView (line 9) | public MainView() => InitializeComponent();
    method DialogHost_OnLoaded (line 11) | private void DialogHost_OnLoaded(object? sender, RoutedEventArgs args) =>
Condensed preview — 252 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,171K chars).
[
  {
    "path": ".docs/Docker.md",
    "chars": 3864,
    "preview": "# Docker usage instructions\n\nDocker distribution of DiscordChatExporter provides a way to run the app in a virtualized a"
  },
  {
    "path": ".docs/Getting-started.md",
    "chars": 2478,
    "preview": "# Getting started\n\nWelcome to the getting started page!\nHere you'll learn how to use every **DiscordChatExporter** (DCE "
  },
  {
    "path": ".docs/Message-filters.md",
    "chars": 2317,
    "preview": "# Message filters\n\nYou can use a special notation to filter messages that you want to have included in an export. The no"
  },
  {
    "path": ".docs/Readme.md",
    "chars": 955,
    "preview": "# Home\n\n## Installation & Usage\n\n- Getting started:\n  - [Using the GUI](Using-the-GUI.md)\n  - [Using the CLI](Using-the-"
  },
  {
    "path": ".docs/Scheduling-Linux.md",
    "chars": 4746,
    "preview": "# Scheduling exports with Cron\n\n## Creating the script\n\n1. Open Terminal and create a new text file with `nano /path/to/"
  },
  {
    "path": ".docs/Scheduling-MacOS.md",
    "chars": 8863,
    "preview": "# Scheduling exports on macOS\n\n## Creating the script\n\n1. Open TextEdit.app and create a new file\n\n2. Convert the file t"
  },
  {
    "path": ".docs/Scheduling-Windows.md",
    "chars": 2656,
    "preview": "# Scheduling exports on Windows\n\n## Creating the script\n\n1. Open a text editor such as Notepad and paste:\n\n```console\n# "
  },
  {
    "path": ".docs/Token-and-IDs.md",
    "chars": 17148,
    "preview": "# Obtaining Token and Channel IDs\n\n> [!WARNING]\n> **Do not share your token!** A token gives full access to an account. "
  },
  {
    "path": ".docs/Troubleshooting.md",
    "chars": 6124,
    "preview": "# Troubleshooting\n\nWelcome to the Frequently Asked Questions (FAQ) and Troubleshooting page!\nHere you'll find the answer"
  },
  {
    "path": ".docs/Using-the-CLI.md",
    "chars": 9998,
    "preview": "# Using the CLI\n\n## Step 1\n\nAfter extracting the `.zip` archive, open your preferred terminal.\n\n## Step 2\n\nChange the cu"
  },
  {
    "path": ".docs/Using-the-GUI.md",
    "chars": 5041,
    "preview": "# Using the GUI\n\n## Video tutorial\n\n[![Video tutorial](https://i.ytimg.com/vi/jjtu0VQXV7I/hqdefault.jpg)](https://youtub"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug-report.yml",
    "chars": 4137,
    "preview": "name: 🐛 Bug report\ndescription: Report broken functionality.\nlabels: [bug]\n\nbody:\n  - type: markdown\n    attributes:\n   "
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "chars": 651,
    "preview": "blank_issues_enabled: false\ncontact_links:\n  - name: ⚠ Feature request\n    url: https://github.com/Tyrrrz/.github/blob/p"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 564,
    "preview": "version: 2\nupdates:\n  - package-ecosystem: github-actions\n    directory: \"/\"\n    schedule:\n      interval: monthly\n    l"
  },
  {
    "path": ".github/workflows/docker.yml",
    "chars": 2468,
    "preview": "name: docker\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - prime\n    tags:\n      - \"*\"\n  pull_request:\n    bra"
  },
  {
    "path": ".github/workflows/main.yml",
    "chars": 7387,
    "preview": "name: main\n\non:\n  workflow_dispatch:\n  push:\n    branches:\n      - prime\n    tags:\n      - \"*\"\n  pull_request:\n    branc"
  },
  {
    "path": ".gitignore",
    "chars": 103,
    "preview": "# User-specific files\n.vs/\n.idea/\n*.suo\n*.user\n\n# Build results\nbin/\nobj/\n\n# Test results\nTestResults/\n"
  },
  {
    "path": "Directory.Build.props",
    "chars": 422,
    "preview": "<Project>\n\n  <PropertyGroup>\n    <TargetFramework>net10.0</TargetFramework>\n    <Version>999.9.9-dev</Version>\n    <Comp"
  },
  {
    "path": "Directory.Packages.props",
    "chars": 2545,
    "preview": "<Project>\n  <PropertyGroup>\n    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>\n  </PropertyGroup>"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs",
    "chars": 2516,
    "preview": "using System;\nusing System.Diagnostics.CodeAnalysis;\nusing System.Threading.Tasks;\nusing CliFx;\nusing CliFx.Attributes;"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs",
    "chars": 15781,
    "preview": "using System;\nusing System.Collections.Concurrent;\nusing System.Collections.Generic;\nusing System.Diagnostics.CodeAnaly"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/Converters/ThreadInclusionModeBindingConverter.cs",
    "chars": 852,
    "preview": "using System;\nusing CliFx.Extensibility;\nusing DiscordChatExporter.Cli.Commands.Shared;\n\nnamespace DiscordChatExporter."
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/Converters/TruthyBooleanBindingConverter.cs",
    "chars": 713,
    "preview": "using System.Globalization;\nusing CliFx.Extensibility;\n\nnamespace DiscordChatExporter.Cli.Commands.Converters;\n\ninterna"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/ExportAllCommand.cs",
    "chars": 5693,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Attributes;"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs",
    "chars": 2127,
    "preview": "using System.Collections.Generic;\nusing System.Threading.Tasks;\nusing CliFx.Attributes;\nusing CliFx.Infrastructure;\nusi"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs",
    "chars": 857,
    "preview": "using System.Threading.Tasks;\nusing CliFx.Attributes;\nusing CliFx.Infrastructure;\nusing DiscordChatExporter.Cli.Command"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs",
    "chars": 2024,
    "preview": "using System.Collections.Generic;\nusing System.Threading.Tasks;\nusing CliFx.Attributes;\nusing CliFx.Infrastructure;\nusi"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs",
    "chars": 4164,
    "preview": "using System;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Attributes;\nusing CliFx.Infrastructure;\nusin"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/GetDirectChannelsCommand.cs",
    "chars": 1583,
    "preview": "using System;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Attributes;\nusing CliFx.Infrastructure;\nusin"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs",
    "chars": 1511,
    "preview": "using System;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Attributes;\nusing CliFx.Infrastructure;\nusin"
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/GuideCommand.cs",
    "chars": 3610,
    "preview": "using System;\nusing System.Threading.Tasks;\nusing CliFx;\nusing CliFx.Attributes;\nusing CliFx.Infrastructure;\n\nnamespace "
  },
  {
    "path": "DiscordChatExporter.Cli/Commands/Shared/ThreadInclusionMode.cs",
    "chars": 120,
    "preview": "namespace DiscordChatExporter.Cli.Commands.Shared;\n\npublic enum ThreadInclusionMode\n{\n    None,\n    Active,\n    All,\n}\n"
  },
  {
    "path": "DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj",
    "chars": 971,
    "preview": "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup>\n    <OutputType>Exe</OutputType>\n    <ApplicationIcon>..\\favicon.ico"
  },
  {
    "path": "DiscordChatExporter.Cli/Program.cs",
    "chars": 2085,
    "preview": "using System.Diagnostics.CodeAnalysis;\nusing System.Threading.Tasks;\nusing CliFx;\nusing DiscordChatExporter.Cli.Command"
  },
  {
    "path": "DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs",
    "chars": 1908,
    "preview": "using System;\nusing System.Threading.Tasks;\nusing CliFx.Infrastructure;\nusing Spectre.Console;\n\nnamespace DiscordChatEx"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj",
    "chars": 1292,
    "preview": "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup>\n    <IsPackable>false</IsPackable>\n    <IsTestProject>true</IsTestPr"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Infra/ChannelIds.cs",
    "chars": 1283,
    "preview": "using DiscordChatExporter.Core.Discord;\n\nnamespace DiscordChatExporter.Cli.Tests.Infra;\n\npublic static class ChannelIds"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs",
    "chars": 4257,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Reflection;\nusing Syst"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Infra/Secrets.cs",
    "chars": 535,
    "preview": "using System;\nusing System.Reflection;\nusing Microsoft.Extensions.Configuration;\n\nnamespace DiscordChatExporter.Cli.Tes"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Readme.md",
    "chars": 622,
    "preview": "# DiscordChatExporter Tests\n\nThis test suite runs against a real Discord server, specifically created to exercise diffe"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/CsvContentSpecs.cs",
    "chars": 730,
    "preview": "using System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing FluentAssertions;\nusing Xunit;\n\nnamespac"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs",
    "chars": 6359,
    "preview": "using System;\nusing System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Infrastructure;\nusing Disco"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/FilterSpecs.cs",
    "chars": 5571,
    "preview": "using System;\nusing System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Infrastructure;\nusing Disco"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlAttachmentSpecs.cs",
    "chars": 2982,
    "preview": "using System;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing DiscordChatExporter.Cli.Test"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlContentSpecs.cs",
    "chars": 2604,
    "preview": "using System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing CliFx.Infrastructure;\nusi"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlEmbedSpecs.cs",
    "chars": 8163,
    "preview": "using System;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing DiscordChatExporter.Cli.Test"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlForwardSpecs.cs",
    "chars": 781,
    "preview": "using System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporte"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlGroupingSpecs.cs",
    "chars": 1834,
    "preview": "using System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing CliFx.Infrastructure;\nusi"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlMarkdownSpecs.cs",
    "chars": 5356,
    "preview": "using System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporte"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs",
    "chars": 2283,
    "preview": "using System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporte"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs",
    "chars": 3140,
    "preview": "using System.Threading.Tasks;\nusing AngleSharp.Dom;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporte"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/HtmlStickerSpecs.cs",
    "chars": 1323,
    "preview": "using System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.Core.Discord;\nusing "
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/JsonAttachmentSpecs.cs",
    "chars": 4229,
    "preview": "using System.Linq;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.C"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs",
    "chars": 2620,
    "preview": "using System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Infrastructure;\nusing DiscordChatExporter"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/JsonEmbedSpecs.cs",
    "chars": 2735,
    "preview": "using System.Linq;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.C"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/JsonEmojiSpecs.cs",
    "chars": 3301,
    "preview": "using System.Linq;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.C"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/JsonForwardSpecs.cs",
    "chars": 1092,
    "preview": "using System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.Core.Discord;\nusing "
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/JsonMentionSpecs.cs",
    "chars": 2713,
    "preview": "using System.Linq;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.C"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/JsonStickerSpecs.cs",
    "chars": 1869,
    "preview": "using System.Linq;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.C"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/PartitioningSpecs.cs",
    "chars": 1779,
    "preview": "using System.IO;\nusing System.Threading.Tasks;\nusing CliFx.Infrastructure;\nusing DiscordChatExporter.Cli.Commands;\nusin"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/PlainTextContentSpecs.cs",
    "chars": 742,
    "preview": "using System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing FluentAssertions;\nusing Xunit;\n\nnamespac"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/PlainTextForwardSpecs.cs",
    "chars": 644,
    "preview": "using System.Threading.Tasks;\nusing DiscordChatExporter.Cli.Tests.Infra;\nusing DiscordChatExporter.Cli.Tests.Utils.Exte"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Specs/SelfContainedSpecs.cs",
    "chars": 1253,
    "preview": "using System.IO;\nusing System.Linq;\nusing System.Threading.Tasks;\nusing CliFx.Infrastructure;\nusing DiscordChatExporter"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Utils/Extensions/StringExtensions.cs",
    "chars": 452,
    "preview": "using System.Text;\n\nnamespace DiscordChatExporter.Cli.Tests.Utils.Extensions;\n\ninternal static class StringExtensions\n{"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Utils/Html.cs",
    "chars": 292,
    "preview": "using AngleSharp.Html.Dom;\nusing AngleSharp.Html.Parser;\n\nnamespace DiscordChatExporter.Cli.Tests.Utils;\n\ninternal stat"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Utils/TempDir.cs",
    "chars": 787,
    "preview": "using System;\nusing System.IO;\nusing System.Reflection;\n\nnamespace DiscordChatExporter.Cli.Tests.Utils;\n\ninternal parti"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/Utils/TempFile.cs",
    "chars": 819,
    "preview": "using System;\nusing System.IO;\nusing System.Reflection;\n\nnamespace DiscordChatExporter.Cli.Tests.Utils;\n\ninternal parti"
  },
  {
    "path": "DiscordChatExporter.Cli.Tests/xunit.runner.json",
    "chars": 139,
    "preview": "{\n  \"$schema\": \"https://xunit.net/schema/current/xunit.runner.schema.json\",\n  \"methodDisplayOptions\": \"all\",\n  \"methodD"
  },
  {
    "path": "DiscordChatExporter.Cli.dockerfile",
    "chars": 2710,
    "preview": "# -- Build\n# Specify the platform here so that we pull the SDK image matching the host platform,\n# instead of the target"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Application.cs",
    "chars": 982,
    "preview": "using System.Text.Json;\nusing DiscordChatExporter.Core.Utils.Extensions;\nusing JsonExtensions.Reading;\n\nnamespace Disco"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/ApplicationFlags.cs",
    "chars": 590,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Discord.Data;\n\n// https://discord.com/developers/docs/resources/appli"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Attachment.cs",
    "chars": 2755,
    "preview": "using System;\nusing System.IO;\nusing System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Data.Common;\nusing Discor"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Channel.cs",
    "chars": 3722,
    "preview": "using System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Dat"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/ChannelConnection.cs",
    "chars": 712,
    "preview": "using System.Collections.Generic;\nusing System.Linq;\n\nnamespace DiscordChatExporter.Core.Discord.Data;\n\npublic record C"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/ChannelKind.cs",
    "chars": 462,
    "preview": "namespace DiscordChatExporter.Core.Discord.Data;\n\n// https://discord.com/developers/docs/resources/channel#channel-obje"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Common/FileSize.cs",
    "chars": 1410,
    "preview": "using System;\nusing System.Diagnostics.CodeAnalysis;\nusing System.Globalization;\n\nnamespace DiscordChatExporter.Core.Di"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Common/IHasId.cs",
    "chars": 112,
    "preview": "namespace DiscordChatExporter.Core.Discord.Data.Common;\n\npublic interface IHasId\n{\n    Snowflake Id { get; }\n}\n"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs",
    "chars": 2966,
    "preview": "using System;\nusing System.Globalization;\nusing System.Linq;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Common;\n\n"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs",
    "chars": 3638,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Drawing;\nusing System.Linq;\nusing System.Text.Json;\nusing D"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/EmbedAuthor.cs",
    "chars": 776,
    "preview": "using System.Text.Json;\nusing JsonExtensions.Reading;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\n// https"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/EmbedField.cs",
    "chars": 611,
    "preview": "using System.Text.Json;\nusing JsonExtensions.Reading;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\n// https"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/EmbedFooter.cs",
    "chars": 670,
    "preview": "using System.Text.Json;\nusing JsonExtensions.Reading;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\n// https"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/EmbedImage.cs",
    "chars": 733,
    "preview": "using System.Text.Json;\nusing JsonExtensions.Reading;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\n// https"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/EmbedKind.cs",
    "chars": 217,
    "preview": "namespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\n// https://discord.com/developers/docs/resources/channel#embed-"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/EmbedVideo.cs",
    "chars": 733,
    "preview": "using System.Text.Json;\nusing JsonExtensions.Reading;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\n// https"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/SpotifyTrackEmbedProjection.cs",
    "chars": 1113,
    "preview": "using System.Text.RegularExpressions;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\npublic partial record S"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/TwitchClipEmbedProjection.cs",
    "chars": 1454,
    "preview": "using System.Text.RegularExpressions;\n\nnamespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\npublic partial record T"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Embeds/YouTubeVideoEmbedProjection.cs",
    "chars": 848,
    "preview": "namespace DiscordChatExporter.Core.Discord.Data.Embeds;\n\npublic partial record YouTubeVideoEmbedProjection(string Video"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Emoji.cs",
    "chars": 1493,
    "preview": "using System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Data.Common;\nusing DiscordChatExporter.Core.Utils;\nusing"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/EmojiIndex.cs",
    "chars": 363192,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Diagnostics.CodeAnalysis;\n\nnamespace DiscordChatExporter.C"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Guild.cs",
    "chars": 1158,
    "preview": "using System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Data.Common;\nusing DiscordChatExporter.Core.Utils.Extens"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Interaction.cs",
    "chars": 686,
    "preview": "using System.Text.Json;\nusing DiscordChatExporter.Core.Utils.Extensions;\nusing JsonExtensions.Reading;\n\nnamespace Disco"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Invite.cs",
    "chars": 851,
    "preview": "using System.Text.Json;\nusing System.Text.RegularExpressions;\nusing DiscordChatExporter.Core.Utils.Extensions;\nusing Js"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Member.cs",
    "chars": 1438,
    "preview": "using System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Dat"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Message.cs",
    "chars": 7197,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing DiscordChatExporter.Co"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/MessageFlags.cs",
    "chars": 373,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Discord.Data;\n\n// https://discord.com/developers/docs/resources/chann"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/MessageKind.cs",
    "chars": 392,
    "preview": "namespace DiscordChatExporter.Core.Discord.Data;\n\n// https://discord.com/developers/docs/resources/channel#message-obje"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/MessageReference.cs",
    "chars": 1133,
    "preview": "using System.Text.Json;\nusing DiscordChatExporter.Core.Utils.Extensions;\nusing JsonExtensions.Reading;\n\nnamespace Discor"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/MessageReferenceKind.cs",
    "chars": 202,
    "preview": "namespace DiscordChatExporter.Core.Discord.Data;\n\n// https://discord.com/developers/docs/resources/channel#message-refer"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/MessageSnapshot.cs",
    "chars": 1673,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json;\nusing DiscordChatExporter.Cor"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Reaction.cs",
    "chars": 479,
    "preview": "using System.Text.Json;\nusing DiscordChatExporter.Core.Utils.Extensions;\n\nnamespace DiscordChatExporter.Core.Discord.Da"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Role.cs",
    "chars": 916,
    "preview": "using System.Drawing;\nusing System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Data.Common;\nusing DiscordChatExpo"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/Sticker.cs",
    "chars": 1271,
    "preview": "using System;\nusing System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Data.Common;\nusing DiscordChatExporter.Cor"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/StickerFormat.cs",
    "chars": 137,
    "preview": "namespace DiscordChatExporter.Core.Discord.Data;\n\npublic enum StickerFormat\n{\n    Png = 1,\n    Apng = 2,\n    Lottie = 3"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Data/User.cs",
    "chars": 2123,
    "preview": "using System.Text.Json;\nusing DiscordChatExporter.Core.Discord.Data.Common;\nusing DiscordChatExporter.Core.Utils.Extens"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/DiscordClient.cs",
    "chars": 31733,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing System.Net;\nusing "
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Dump/DataDump.cs",
    "chars": 1964,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO.Compression;\nusing System.Linq;\nusing System.Text.Json;"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Dump/DataDumpChannel.cs",
    "chars": 109,
    "preview": "namespace DiscordChatExporter.Core.Discord.Dump;\n\npublic record DataDumpChannel(Snowflake Id, string Name);\n"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/RateLimitPreference.cs",
    "chars": 1323,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Discord;\n\n[Flags]\npublic enum RateLimitPreference\n{\n    IgnoreAll = 0"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/Snowflake.cs",
    "chars": 2072,
    "preview": "using System;\nusing System.Diagnostics.CodeAnalysis;\nusing System.Globalization;\n\nnamespace DiscordChatExporter.Core.Di"
  },
  {
    "path": "DiscordChatExporter.Core/Discord/TokenKind.cs",
    "chars": 91,
    "preview": "namespace DiscordChatExporter.Core.Discord;\n\npublic enum TokenKind\n{\n    User,\n    Bot,\n}\n"
  },
  {
    "path": "DiscordChatExporter.Core/DiscordChatExporter.Core.csproj",
    "chars": 570,
    "preview": "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <ItemGroup>\n    <PackageReference Include=\"AngleSharp\" />\n    <PackageReference Incl"
  },
  {
    "path": "DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs",
    "chars": 140,
    "preview": "namespace DiscordChatExporter.Core.Exceptions;\n\npublic class ChannelEmptyException(string message) : DiscordChatExporter"
  },
  {
    "path": "DiscordChatExporter.Core/Exceptions/DiscordChatExporterException.cs",
    "chars": 277,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Exceptions;\n\npublic class DiscordChatExporterException(\n    string me"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/ChannelExporter.cs",
    "chars": 4010,
    "preview": "using System;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Core.Discord;\nusing Disco"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/CsvMessageWriter.cs",
    "chars": 3851,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Text;\nusing System.Threading;\nusing Syste"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs",
    "chars": 5221,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Security.Cryptography;\nusing System.Text;"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/ExportContext.cs",
    "chars": 6122,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Drawing;\nusing System.IO;\nusing System.Linq;\nusing System."
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/ExportFormat.cs",
    "chars": 1086,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Exporting;\n\npublic enum ExportFormat\n{\n    PlainText,\n    HtmlDark,\n "
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/ExportRequest.cs",
    "chars": 6556,
    "preview": "using System;\nusing System.Globalization;\nusing System.IO;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusi"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/BinaryExpressionKind.cs",
    "chars": 114,
    "preview": "namespace DiscordChatExporter.Core.Exporting.Filtering;\n\ninternal enum BinaryExpressionKind\n{\n    Or,\n    And,\n}\n"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/BinaryExpressionMessageFilter.cs",
    "chars": 641,
    "preview": "using System;\nusing DiscordChatExporter.Core.Discord.Data;\n\nnamespace DiscordChatExporter.Core.Exporting.Filtering;\n\nin"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs",
    "chars": 1268,
    "preview": "using System.Linq;\nusing System.Text.RegularExpressions;\nusing DiscordChatExporter.Core.Discord.Data;\n\nnamespace Discor"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/FromMessageFilter.cs",
    "chars": 612,
    "preview": "using System;\nusing DiscordChatExporter.Core.Discord.Data;\n\nnamespace DiscordChatExporter.Core.Exporting.Filtering;\n\nin"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/HasMessageFilter.cs",
    "chars": 1318,
    "preview": "using System;\nusing System.Linq;\nusing DiscordChatExporter.Core.Discord.Data;\nusing DiscordChatExporter.Core.Markdown.P"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs",
    "chars": 664,
    "preview": "using System;\nusing System.Linq;\nusing DiscordChatExporter.Core.Discord.Data;\n\nnamespace DiscordChatExporter.Core.Expor"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/MessageContentMatchKind.cs",
    "chars": 185,
    "preview": "namespace DiscordChatExporter.Core.Exporting.Filtering;\n\ninternal enum MessageContentMatchKind\n{\n    Link,\n    Embed,\n "
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/MessageFilter.cs",
    "chars": 485,
    "preview": "using DiscordChatExporter.Core.Discord.Data;\nusing DiscordChatExporter.Core.Exporting.Filtering.Parsing;\nusing Superpow"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/NegatedMessageFilter.cs",
    "chars": 261,
    "preview": "using DiscordChatExporter.Core.Discord.Data;\n\nnamespace DiscordChatExporter.Core.Exporting.Filtering;\n\ninternal class N"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/NullMessageFilter.cs",
    "chars": 216,
    "preview": "using DiscordChatExporter.Core.Discord.Data;\n\nnamespace DiscordChatExporter.Core.Exporting.Filtering;\n\ninternal class N"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/Parsing/FilterGrammar.cs",
    "chars": 5355,
    "preview": "using DiscordChatExporter.Core.Utils.Extensions;\nusing Superpower;\nusing Superpower.Parsers;\n\nnamespace DiscordChatExpo"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs",
    "chars": 572,
    "preview": "using System;\nusing System.Linq;\nusing DiscordChatExporter.Core.Discord.Data;\n\nnamespace DiscordChatExporter.Core.Expor"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs",
    "chars": 10931,
    "preview": "using System;\nusing System.Linq;\nusing System.Net;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing Syste"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/HtmlMessageExtensions.cs",
    "chars": 793,
    "preview": "using System;\nusing DiscordChatExporter.Core.Discord.Data;\nusing DiscordChatExporter.Core.Discord.Data.Embeds;\n\nnamespac"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs",
    "chars": 4522,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading;\nusing Syste"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs",
    "chars": 20036,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text.Encodings.Web;\nus"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/MessageExporter.cs",
    "chars": 3984,
    "preview": "using System;\nusing System.IO;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Core.Dis"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml",
    "chars": 48491,
    "preview": "@using System\n@using System.Collections.Generic\n@using System.Linq\n@using System.Threading.Tasks\n@using DiscordChatExpor"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/MessageWriter.cs",
    "chars": 998,
    "preview": "using System;\nusing System.IO;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Core.Dis"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Partitioning/FileSizePartitionLimit.cs",
    "chars": 242,
    "preview": "namespace DiscordChatExporter.Core.Exporting.Partitioning;\n\ninternal class FileSizePartitionLimit(long limit) : Partiti"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Partitioning/MessageCountPartitionLimit.cs",
    "chars": 249,
    "preview": "namespace DiscordChatExporter.Core.Exporting.Partitioning;\n\ninternal class MessageCountPartitionLimit(long limit) : Par"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Partitioning/NullPartitionLimit.cs",
    "chars": 202,
    "preview": "namespace DiscordChatExporter.Core.Exporting.Partitioning;\n\ninternal class NullPartitionLimit : PartitionLimit\n{\n    pu"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/Partitioning/PartitionLimit.cs",
    "chars": 1938,
    "preview": "using System;\nusing System.Globalization;\nusing System.Text.RegularExpressions;\n\nnamespace DiscordChatExporter.Core.Exp"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs",
    "chars": 3639,
    "preview": "using System.Text;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing DiscordChatExporter.Core.Markdown;\nusing"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/PlainTextMessageExtensions.cs",
    "chars": 1836,
    "preview": "using System.Globalization;\nusing System.Linq;\nusing DiscordChatExporter.Core.Discord.Data;\nusing DiscordChatExporter.C"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/PlainTextMessageWriter.cs",
    "chars": 9467,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading;\nusing Syste"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/PostambleTemplate.cshtml",
    "chars": 648,
    "preview": "@using System\n\n@inherits RazorBlade.HtmlTemplate\n\n@functions {\n    public required ExportContext Context { get; init; }"
  },
  {
    "path": "DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml",
    "chars": 35261,
    "preview": "@using System\n@using System.Threading.Tasks\n\n@inherits RazorBlade.HtmlTemplate\n\n@functions {\n    public required Export"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/EmojiNode.cs",
    "chars": 916,
    "preview": "using DiscordChatExporter.Core.Discord;\nusing DiscordChatExporter.Core.Discord.Data;\n\nnamespace DiscordChatExporter.Cor"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/FormattingKind.cs",
    "chars": 160,
    "preview": "namespace DiscordChatExporter.Core.Markdown;\n\ninternal enum FormattingKind\n{\n    Bold,\n    Italic,\n    Underline,\n    S"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/FormattingNode.cs",
    "chars": 216,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\ninternal record FormattingNode(Formatt"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/HeadingNode.cs",
    "chars": 203,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\ninternal record HeadingNode(int Level,"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/IContainerNode.cs",
    "chars": 170,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\ninternal interface IContainerNode\n{\n  "
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/InlineCodeBlockNode.cs",
    "chars": 112,
    "preview": "namespace DiscordChatExporter.Core.Markdown;\n\ninternal record InlineCodeBlockNode(string Code) : MarkdownNode;\n"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/LinkNode.cs",
    "chars": 360,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\n// Named links can contain child nodes"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/ListItemNode.cs",
    "chars": 181,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\ninternal record ListItemNode(IReadOnly"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/ListNode.cs",
    "chars": 158,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\ninternal record ListNode(IReadOnlyList"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/MarkdownNode.cs",
    "chars": 86,
    "preview": "namespace DiscordChatExporter.Core.Markdown;\n\ninternal abstract record MarkdownNode;\n"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/MentionKind.cs",
    "chars": 134,
    "preview": "namespace DiscordChatExporter.Core.Markdown;\n\ninternal enum MentionKind\n{\n    Everyone,\n    Here,\n    User,\n    Channel"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/MentionNode.cs",
    "chars": 230,
    "preview": "using DiscordChatExporter.Core.Discord;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\n// Null ID means it's a meta men"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/MultiLineCodeBlockNode.cs",
    "chars": 132,
    "preview": "namespace DiscordChatExporter.Core.Markdown;\n\ninternal record MultiLineCodeBlockNode(string Language, string Code) : Ma"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/AggregateMatcher.cs",
    "chars": 1300,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown.Parsing;\n\ninternal class AggregateMatche"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/IMatcher.cs",
    "chars": 2114,
    "preview": "using System;\nusing System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Markdown.Parsing;\n\ninternal interfa"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/MarkdownContext.cs",
    "chars": 119,
    "preview": "namespace DiscordChatExporter.Core.Markdown.Parsing;\n\ninternal readonly record struct MarkdownContext(int Depth = 0);\n"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs",
    "chars": 21882,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing System.Text.Regula"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/MarkdownVisitor.cs",
    "chars": 4298,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Threading;\nusing System.Threading.Tasks;\n\nnamespace Discor"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/ParsedMatch.cs",
    "chars": 212,
    "preview": "namespace DiscordChatExporter.Core.Markdown.Parsing;\n\ninternal class ParsedMatch<T>(StringSegment segment, T value)\n{\n "
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/RegexMatcher.cs",
    "chars": 1237,
    "preview": "using System;\nusing System.Text.RegularExpressions;\n\nnamespace DiscordChatExporter.Core.Markdown.Parsing;\n\ninternal cla"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/StringMatcher.cs",
    "chars": 866,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Markdown.Parsing;\n\ninternal class StringMatcher<TContext, TValue>(\n  "
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/Parsing/StringSegment.cs",
    "chars": 608,
    "preview": "using System.Text.RegularExpressions;\n\nnamespace DiscordChatExporter.Core.Markdown.Parsing;\n\ninternal readonly record s"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/TextNode.cs",
    "chars": 101,
    "preview": "namespace DiscordChatExporter.Core.Markdown;\n\ninternal record TextNode(string Text) : MarkdownNode;\n"
  },
  {
    "path": "DiscordChatExporter.Core/Markdown/TimestampNode.cs",
    "chars": 257,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Markdown;\n\n// Null date means invalid timestamp\ninternal record Times"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Docker.cs",
    "chars": 230,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Utils;\n\npublic static class Docker\n{\n    public static bool IsRunning"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/AsyncCollectionExtensions.cs",
    "chars": 631,
    "preview": "using System.Collections.Generic;\nusing System.Runtime.CompilerServices;\nusing System.Threading.Tasks;\n\nnamespace Disco"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/CollectionExtensions.cs",
    "chars": 842,
    "preview": "using System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static class Collection"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/ColorExtensions.cs",
    "chars": 426,
    "preview": "using System.Drawing;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static class ColorExtensions\n{\n    "
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/ExceptionExtensions.cs",
    "chars": 1052,
    "preview": "using System;\nusing System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static cl"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/GenericExtensions.cs",
    "chars": 535,
    "preview": "using System;\nusing System.Collections.Generic;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static cl"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/HttpExtensions.cs",
    "chars": 311,
    "preview": "using System.Net.Http.Headers;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static class HttpExtension"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/PathExtensions.cs",
    "chars": 1784,
    "preview": "using System;\nusing System.IO;\nusing System.Text;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static "
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/StringExtensions.cs",
    "chars": 889,
    "preview": "using System.Text;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static class StringExtensions\n{\n    ex"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/SuperpowerExtensions.cs",
    "chars": 845,
    "preview": "using System;\nusing System.Diagnostics.CodeAnalysis;\nusing Superpower;\nusing Superpower.Parsers;\n\nnamespace DiscordChat"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Extensions/TimeSpanExtensions.cs",
    "chars": 375,
    "preview": "using System;\n\nnamespace DiscordChatExporter.Core.Utils.Extensions;\n\npublic static class TimeSpanExtensions\n{\n    exten"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Http.cs",
    "chars": 2975,
    "preview": "using System;\nusing System.Linq;\nusing System.Net;\nusing System.Net.Http;\nusing System.Net.Sockets;\nusing System.Securi"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/Url.cs",
    "chars": 1557,
    "preview": "using System;\nusing System.IO;\nusing System.Text;\n\nnamespace DiscordChatExporter.Core.Utils;\n\npublic static class Url\n{"
  },
  {
    "path": "DiscordChatExporter.Core/Utils/UrlBuilder.cs",
    "chars": 1195,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text;\nusing DiscordChatExporter.Core.Ut"
  },
  {
    "path": "DiscordChatExporter.Gui/App.axaml",
    "chars": 6813,
    "preview": "<Application\n    x:Class=\"DiscordChatExporter.Gui.App\"\n    xmlns=\"https://github.com/avaloniaui\"\n    xmlns:x=\"http://sc"
  },
  {
    "path": "DiscordChatExporter.Gui/App.axaml.cs",
    "chars": 4754,
    "preview": "using System;\nusing Avalonia;\nusing Avalonia.Controls.ApplicationLifetimes;\nusing Avalonia.Markup.Xaml;\nusing Avalonia."
  },
  {
    "path": "DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs",
    "chars": 715,
    "preview": "using System;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\nusing DiscordChatExporter.Core.Discord.Data;\n"
  },
  {
    "path": "DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs",
    "chars": 691,
    "preview": "using System;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\nusing DiscordChatExporter.Core.Exporting;\n\nna"
  },
  {
    "path": "DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs",
    "chars": 708,
    "preview": "using System;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\n\nnamespace DiscordChatExporter.Gui.Converters"
  },
  {
    "path": "DiscordChatExporter.Gui/Converters/MarkdownToInlinesConverter.cs",
    "chars": 5331,
    "preview": "using System;\nusing System.Globalization;\nusing System.Linq;\nusing Avalonia.Controls.Documents;\nusing Avalonia.Data.Conv"
  },
  {
    "path": "DiscordChatExporter.Gui/Converters/RateLimitPreferenceToStringConverter.cs",
    "chars": 768,
    "preview": "using System;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\nusing DiscordChatExporter.Core.Discord;\n\nname"
  },
  {
    "path": "DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs",
    "chars": 716,
    "preview": "using System;\nusing System.Globalization;\nusing Avalonia.Data.Converters;\nusing DiscordChatExporter.Core.Discord;\n\nname"
  },
  {
    "path": "DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj",
    "chars": 2564,
    "preview": "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <PropertyGroup>\n    <OutputType>WinExe</OutputType>\n    <AssemblyName>DiscordChatExp"
  },
  {
    "path": "DiscordChatExporter.Gui/Framework/DialogManager.cs",
    "chars": 3169,
    "preview": "using System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Threading;\nusing Syste"
  },
  {
    "path": "DiscordChatExporter.Gui/Framework/DialogViewModelBase.cs",
    "chars": 733,
    "preview": "using System.Threading.Tasks;\nusing CommunityToolkit.Mvvm.ComponentModel;\nusing CommunityToolkit.Mvvm.Input;\n\nnamespace"
  },
  {
    "path": "DiscordChatExporter.Gui/Framework/SnackbarManager.cs",
    "chars": 937,
    "preview": "using System;\nusing Avalonia.Threading;\nusing Material.Styles.Controls;\nusing Material.Styles.Models;\n\nnamespace Discor"
  },
  {
    "path": "DiscordChatExporter.Gui/Framework/ThemeVariant.cs",
    "chars": 109,
    "preview": "namespace DiscordChatExporter.Gui.Framework;\n\npublic enum ThemeVariant\n{\n    System,\n    Light,\n    Dark,\n}\n"
  },
  {
    "path": "DiscordChatExporter.Gui/Framework/UserControl.cs",
    "chars": 514,
    "preview": "using System;\nusing Avalonia.Controls;\n\nnamespace DiscordChatExporter.Gui.Framework;\n\npublic class UserControl<TDataCon"
  }
]

// ... and 52 more files (download for full content)

About this extraction

This page contains the full source code of the Tyrrrz/DiscordChatExporter GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 252 files (1.0 MB), approximately 306.1k tokens, and a symbol index with 644 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!