mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 06:39:33 +02:00
Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cba5a934df | |||
| cd05058cf7 | |||
| e9c3872701 | |||
| 88c4330a66 | |||
| 998bdaf2d4 | |||
| 24a80f915f | |||
| de57cd714d | |||
| f9ab269b29 | |||
| 33feaccee7 | |||
| 4fd6f8272e | |||
| b28f029382 | |||
| 29552be6e5 | |||
| 9491e18e2f | |||
| 4b1c9660be | |||
| 950625e671 | |||
| ce2dedff6b | |||
| baac419538 | |||
| bd40dbde48 | |||
| cbe4bc593c | |||
| cb32cee5f5 | |||
| 82f364a887 | |||
| 152e8c8dcb | |||
| 260139ab25 | |||
| 11e9c0f15c | |||
| ac809d1a3f | |||
| 36fa346299 | |||
| 8c3a5bd096 | |||
| 021682899a | |||
| 0ec6027a93 | |||
| eb49d0bd16 | |||
| e7c2564f6f | |||
| d1b70cf5f6 | |||
| b73d6a78a9 | |||
| b9f04bc4d9 | |||
| d01ed13041 | |||
| ed35a36eaa | |||
| 2ddfb46c31 | |||
| 3c5beeba79 | |||
| ed915443e6 | |||
| f8d55f406c | |||
| 15b605f5a7 | |||
| 511af1e35c | |||
| 2fea455c64 | |||
| c64133f717 | |||
| d18d2e0307 | |||
| 86633b858c | |||
| c390c64316 | |||
| af3a59ce0f | |||
| eb89ea5b40 | |||
| ad3655396f | |||
| 1b8af364d3 | |||
| 58464d8708 | |||
| 026927265a | |||
| 390735032e | |||
| 461d474498 | |||
| 017ed5ae6d | |||
| 6f90c367b9 | |||
| f866c16355 | |||
| 072d4c1fe6 | |||
| 6365e0a080 | |||
| 7b140fd450 | |||
| e237e00600 | |||
| 3aef7faa1e | |||
| ebe4d58a42 | |||
| bed0ade732 | |||
| 06e89538cb | |||
| 7903869f9f | |||
| 32443c5a5b | |||
| d477159907 | |||
| 93578abe24 | |||
| f22b048d32 | |||
| a8031ad3aa | |||
| e49cf997ea | |||
| 8b9afe45b9 | |||
| 77b7977324 | |||
| 915f4c8d9f | |||
| 0872d6d44b |
@@ -0,0 +1,55 @@
|
||||
name: "\U0001F41E Bug report"
|
||||
description: Report broken functionality.
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Important:
|
||||
|
||||
- Please check existing issues (both opened and closed) to ensure that this bug hasn't been reported before.
|
||||
- Refer to the [contribution guidelines](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Contributing.md) for info on how to file a good issue.
|
||||
- If you want to ask a question instead of reporting a bug, please use [discussions](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: Version
|
||||
description: "Which version(s) of DiscordChatExporter does this bug affect?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Flavor
|
||||
description: "Which flavor(s) of DiscordChatExporter does this bug affect?"
|
||||
multiple: true
|
||||
options:
|
||||
- GUI (Graphical User Interface)
|
||||
- CLI (Command Line Interface)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Export format
|
||||
description: "Which export format(s) does this bug affect? If this bug is not related to any specific export format, leave this blank."
|
||||
multiple: true
|
||||
options:
|
||||
- HTML
|
||||
- TXT
|
||||
- JSON
|
||||
- CSV
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: "Clear and thorough explanation of the bug. If relevant, include screenshots or screen recordings."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: "Minimum steps required to reproduce the bug."
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "\U0001F4D6 Read the wiki"
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/wiki
|
||||
about: Find answers to most common questions.
|
||||
- name: "\U0001F5E8 Ask a question"
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
|
||||
about: Please ask and answer questions here.
|
||||
@@ -0,0 +1,39 @@
|
||||
name: "\U00002728 Feature request"
|
||||
description: Request a new feature.
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Important:
|
||||
|
||||
- Please check existing issues (both opened and closed) to ensure that this feature hasn't been requested before.
|
||||
- Refer to the [contribution guidelines](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Contributing.md) for info on how to file a good issue.
|
||||
- If you want to ask a question instead of requesting a feature, please use [discussions](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Flavor
|
||||
description: "Which flavor(s) of DiscordChatExporter does this feature request apply to? If this feature is not related to any specific flavor, leave this blank."
|
||||
multiple: true
|
||||
options:
|
||||
- GUI (Graphical User Interface)
|
||||
- CLI (Command Line Interface)
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Export format
|
||||
description: "Which export format(s) does this feature request apply to? If this feature is not related to any specific export format, leave this blank."
|
||||
multiple: true
|
||||
options:
|
||||
- HTML
|
||||
- TXT
|
||||
- JSON
|
||||
- CSV
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: "Clear and thorough explanation of the feature you have in mind. If relevant, include screenshots or screen recordings."
|
||||
validations:
|
||||
required: true
|
||||
@@ -4,6 +4,7 @@
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
.idea/
|
||||
.vs/
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 33 KiB |
@@ -1,3 +1,42 @@
|
||||
### v2.28 (19-Jun-2021)
|
||||
|
||||
- [HTML] Added special casing for YouTube video embeds to render them directly using an embedded player. (Thanks [@quentinmay](https://github.com/quentinmay))
|
||||
- [HTML] Added support for rendering standard emoji by code. Such emoji may sometimes be found in messages sent by bots or through webhooks. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- [HTML] Changed tooltips on standard emoji to show emoji code instead of their raw string representation. (Thanks [@CanePlayz](https://github.com/CanePlayz) and [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- [HTML] Added tooltips on individual messages to show when those messages were sent.
|
||||
- [HTML] Updated colors and minor styling elements to match Discord's new style direction after rebranding.
|
||||
- [HTML] Updated Twemoji URLs to use SVG image variants for better rendering quality.
|
||||
- [HTML] Changed user avatar URLs to include predetermined size query parameter for better rendering quality.
|
||||
- Changed embed rendering logic to prefer Discord-proxied URLs for external content, in order to avoid unnecessary HTTP requests to third parties.
|
||||
- Fixed an issue where exporting with media sometimes resulted in a crash due to lack of permissions to change file attributes. These errors are now ignored, which means that the creation, write, and access dates of downloaded files may sometimes not match the dates provided by Discord's CDN.
|
||||
- [HTML] Fixed styling issues when rendering embed footers.
|
||||
- [HTML] Fixed an issue where links didn't render properly inside referenced messages.
|
||||
- [HTML] Fixed an issue where standalone emoji were incorrectly enlarged inside referenced messages.
|
||||
- [HTML] Fixed an issue where clicking on a referenced message revealed spoilers inside of it.
|
||||
- [GUI] Fixed an issue where the list of missing components, reported by the prerequisite check at application startup, included Windows updates that were not applicable to the user's system due to the fact that other superseding updates have already been installed. This issue only affected users running DiscordChatExporter on Windows 7.
|
||||
- [GUI] Fixed a few other minor issues related to the prerequisite installation window.
|
||||
|
||||
### v2.27.1 (08-Jun-2021)
|
||||
|
||||
- [GUI] Application will now detect if the required .NET Runtime or any of its prerequisites are missing and prompt the user to download and install them automatically. **Experimental feature, please test it out and report any issues you may find!**
|
||||
- [CLI] Fixed an issue where the application crashed when exporting a channel that had square brackets as part of its name or category name. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
|
||||
### v2.27 (24-Apr-2021)
|
||||
|
||||
- Added partitioning by file size. You can now use values such as `10mb` to indicate a size-based cut off point, in addition to values like `10` to indicate a number of messages. (Thanks [@Andrew Kolos](https://github.com/andrewkolos))
|
||||
- [CLI] Improved formatting and progress reporting.
|
||||
- [CLI] Changed `export` command so that it can accept multiple channel IDs passed to `-c|--channel` option. This makes it possible to export multiple channels at once while benefiting from parallelization.
|
||||
- [JSON] Added `color` field to message author. This is a hex string that represents user's color, inherited from their roles. (Thanks [@rtm516](https://github.com/rtm516))
|
||||
- [JSON] Added `categoryId` field to the root. This represents the ID of the category channel that the currently exported channel belongs to. (Thanks [@rtm516](https://github.com/rtm516))
|
||||
- [JSON] Fixed an issue where user discriminator was written without leading zeroes. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- [JSON] Fixed an issue where the application sometimes crashed with an error saying `']' invalid without a matching open`, which was just a validation message that masked the actual error behind the failure. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- Fixed an issue where progress was not reported correctly if the channel didn't have any messages in the specified time range.
|
||||
|
||||
### v2.26.1 (06-Feb-2021)
|
||||
|
||||
- [CLI] Added support for file name templates, which allow you to dynamically generate output file names based on channel and guild metadata. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
- Fixed an issue where the application sometimes crashed with `Invalid snowflake` message when exporting chat logs that contained invalid or outdated mentions. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
|
||||
|
||||
### v2.26 (29-Dec-2020)
|
||||
|
||||
- [HTML] Added support for replies. (Thanks [@Sanqui](https://github.com/Sanqui))
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Contributing
|
||||
|
||||
DiscordChatExporter accepts contributions in the form of issues and pull requests.
|
||||
|
||||
## Creating issues
|
||||
|
||||
If you have a feature suggestion or want to report a bug, you are welcome to create an issue.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Avoid creating an issue if a similar one already exists. Look through existing open and closed issues first.
|
||||
- Keep your issue focused on one specific problem. If you have multiple suggestions or bug reports, please create separate issues for them.
|
||||
- Provide a descriptive title for your issue. Don't use generic titles like "A couple suggestions" or "Not working".
|
||||
- Provide more context in the body of the issue. If relevant, attach screenshots or screen recordings.
|
||||
- Remain civil and respectful when participating in discussions.
|
||||
|
||||
## Creating pull requests
|
||||
|
||||
If you want to contribute code to the project, you can do so by creating a pull request.
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Make sure that there is an existing issue that describes the problem solved by your pull request. If there isn't one, please create it first.
|
||||
- Add a comment to the issue indicating that you want to submit a pull request for it. This can be a good opportunity to discuss possible solutions, finalize requirements, or ask questions.
|
||||
- Keep your pull request focused and as small as possible. If you want to contribute multiple unrelated changes, please create separate pull requests for them.
|
||||
- Follow the coding style and conventions already established by the project. When in doubt which style to use, ask in comments to your pull request.
|
||||
- If you want to start a discussion regarding a specific change you made, add a review comment to your own code. This can be used to highlight something important or to seek input from others.
|
||||
|
||||
## Building the project locally
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- [.NET 5.0 SDK](https://dotnet.microsoft.com/download/dotnet/5.0)
|
||||
- [.NET 3.1 SDK](https://dotnet.microsoft.com/download/dotnet/3.1) (temporarily as the app still targets .NET 3.1, but requires .NET 5.0 to build)
|
||||
- _(Optional)_ C#/.NET IDE, such as [JetBrains Rider](https://www.jetbrains.com/rider), [VS Code](https://code.visualstudio.com/docs/languages/csharp), or [Visual Studio](https://visualstudio.microsoft.com).
|
||||
|
||||
To build the entire solution run the following command in the root of the repository:
|
||||
|
||||
```sh
|
||||
> dotnet build
|
||||
```
|
||||
|
||||
This will generate runtime artifacts for each project:
|
||||
|
||||
```plaintext
|
||||
./DiscordChatExporter.Gui/bin/[Debug|Release]/[runtime]/*
|
||||
./DiscordChatExporter.Cli/bin/[Debug|Release]/[runtime]/*
|
||||
```
|
||||
|
||||
You can also build and run a specific project directly.
|
||||
To do that, navigate to its directory and use `dotnet run`:
|
||||
|
||||
```sh
|
||||
> cd DiscordChatExporter.Gui
|
||||
> dotnet run
|
||||
```
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<Version>2.26</Version>
|
||||
<Version>2.28</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
@@ -10,4 +10,4 @@
|
||||
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,12 +1,20 @@
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Utilities;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting;
|
||||
using CliFx.Infrastructure;
|
||||
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.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
@@ -24,8 +32,11 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
[CommandOption("before", Description = "Only include messages sent before this date or message ID.")]
|
||||
public Snowflake? Before { get; init; }
|
||||
|
||||
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
|
||||
public int? PartitionLimit { get; init; }
|
||||
[CommandOption("partition", 'p', Description = "Split output into partitions, each limited to this number of messages (e.g. 100) or file size (e.g. 10mb).")]
|
||||
public PartitionLimit PartitionLimit { get; init; } = NullPartitionLimit.Instance;
|
||||
|
||||
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
|
||||
[CommandOption("media", Description = "Download referenced media content.")]
|
||||
public bool ShouldDownloadMedia { get; init; }
|
||||
@@ -36,49 +47,96 @@ namespace DiscordChatExporter.Cli.Commands.Base
|
||||
[CommandOption("dateformat", Description = "Format used when writing dates.")]
|
||||
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
|
||||
|
||||
protected ChannelExporter GetChannelExporter() => new(GetDiscordClient());
|
||||
private ChannelExporter? _channelExporter;
|
||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, Guild guild, Channel channel)
|
||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
console.Output.Write($"Exporting channel '{channel.Category} / {channel.Name}'... ");
|
||||
var progress = console.CreateProgressTicker();
|
||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
||||
|
||||
var request = new ExportRequest(
|
||||
guild,
|
||||
channel,
|
||||
OutputPath,
|
||||
ExportFormat,
|
||||
After,
|
||||
Before,
|
||||
PartitionLimit,
|
||||
ShouldDownloadMedia,
|
||||
ShouldReuseMedia,
|
||||
DateFormat
|
||||
);
|
||||
var errors = new ConcurrentDictionary<Channel, string>();
|
||||
|
||||
await GetChannelExporter().ExportChannelAsync(request, progress);
|
||||
// Wrap everything in a progress ticker
|
||||
await console.CreateProgressTicker().StartAsync(async progressContext =>
|
||||
{
|
||||
await channels.ParallelForEachAsync(async channel =>
|
||||
{
|
||||
// Export
|
||||
try
|
||||
{
|
||||
await progressContext.StartTaskAsync($"{channel.Category} / {channel.Name}", async progress =>
|
||||
{
|
||||
var guild = await Discord.GetGuildAsync(channel.GuildId);
|
||||
|
||||
console.Output.WriteLine();
|
||||
console.Output.WriteLine("Done.");
|
||||
}
|
||||
var request = new ExportRequest(
|
||||
guild,
|
||||
channel,
|
||||
OutputPath,
|
||||
ExportFormat,
|
||||
After,
|
||||
Before,
|
||||
PartitionLimit,
|
||||
ShouldDownloadMedia,
|
||||
ShouldReuseMedia,
|
||||
DateFormat
|
||||
);
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, Channel channel)
|
||||
{
|
||||
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
|
||||
await ExportAsync(console, guild, channel);
|
||||
}
|
||||
await Exporter.ExportChannelAsync(request, progress);
|
||||
});
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
|
||||
{
|
||||
errors[channel] = ex.Message;
|
||||
}
|
||||
}, ParallelLimit.ClampMin(1));
|
||||
});
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, Snowflake channelId)
|
||||
{
|
||||
var channel = await GetDiscordClient().GetChannelAsync(channelId);
|
||||
await ExportAsync(console, channel);
|
||||
// Print result
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Successfully exported {channels.Count - errors.Count} channel(s)."
|
||||
);
|
||||
}
|
||||
|
||||
// Print errors
|
||||
if (errors.Any())
|
||||
{
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Failed to export {errors.Count} channel(s):"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var (channel, error) in errors)
|
||||
{
|
||||
await console.Output.WriteAsync($"{channel.Category} / {channel.Name}: ");
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
await console.Output.WriteLineAsync(error);
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
}
|
||||
|
||||
// Fail the command if ALL channels failed to export.
|
||||
// Having some of the channels fail to export is fine and expected.
|
||||
if (errors.Count >= channels.Count)
|
||||
{
|
||||
throw new CommandException("Export failed.");
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync("Done.");
|
||||
}
|
||||
|
||||
public override ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
if (ShouldReuseMedia && !ShouldDownloadMedia)
|
||||
{
|
||||
throw new CommandException("The --reuse-media option cannot be used without the --media option.");
|
||||
throw new CommandException("Option --reuse-media cannot be used without --media.");
|
||||
}
|
||||
|
||||
return default;
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Utilities;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Exporting;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using Gress;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
public abstract class ExportMultipleCommandBase : ExportCommandBase
|
||||
{
|
||||
[CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
|
||||
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
// This uses a different route from ExportCommandBase.ExportAsync() because it runs
|
||||
// in parallel and needs another way to report progress to console.
|
||||
|
||||
console.Output.Write($"Exporting {channels.Count} channels... ");
|
||||
var progress = console.CreateProgressTicker();
|
||||
|
||||
var operations = progress.Wrap().CreateOperations(channels.Count);
|
||||
|
||||
var successfulExportCount = 0;
|
||||
var errors = new ConcurrentBag<(Channel, string)>();
|
||||
|
||||
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
|
||||
{
|
||||
var (channel, operation) = tuple;
|
||||
|
||||
try
|
||||
{
|
||||
var guild = await GetDiscordClient().GetGuildAsync(channel.GuildId);
|
||||
|
||||
var request = new ExportRequest(
|
||||
guild,
|
||||
channel,
|
||||
OutputPath,
|
||||
ExportFormat,
|
||||
After,
|
||||
Before,
|
||||
PartitionLimit,
|
||||
ShouldDownloadMedia,
|
||||
ShouldReuseMedia,
|
||||
DateFormat
|
||||
);
|
||||
|
||||
await GetChannelExporter().ExportChannelAsync(request, operation);
|
||||
|
||||
Interlocked.Increment(ref successfulExportCount);
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsCritical)
|
||||
{
|
||||
errors.Add((channel, ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
operation.Dispose();
|
||||
}
|
||||
}, ParallelLimit.ClampMin(1));
|
||||
|
||||
console.Output.WriteLine();
|
||||
|
||||
foreach (var (channel, error) in errors)
|
||||
console.Error.WriteLine($"Channel '{channel}': {error}");
|
||||
|
||||
console.Output.WriteLine($"Successfully exported {successfulExportCount} channel(s).");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,28 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base
|
||||
{
|
||||
public abstract class TokenCommandBase : ICommand
|
||||
{
|
||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN", Description = "Authorization token.")]
|
||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
|
||||
public string TokenValue { get; init; } = "";
|
||||
|
||||
[CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT", Description = "Authorize as a bot.")]
|
||||
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")]
|
||||
public bool IsBotToken { get; init; }
|
||||
|
||||
protected AuthToken GetAuthToken() => new(
|
||||
private AuthToken GetAuthToken() => new(
|
||||
IsBotToken
|
||||
? AuthTokenType.Bot
|
||||
: AuthTokenType.User,
|
||||
TokenValue
|
||||
);
|
||||
|
||||
protected DiscordClient GetDiscordClient() => new(GetAuthToken());
|
||||
private DiscordClient? _discordClient;
|
||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(GetAuthToken());
|
||||
|
||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportall", Description = "Export all accessible channels.")]
|
||||
public class ExportAllCommand : ExportMultipleCommandBase
|
||||
public class ExportAllCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
||||
public bool IncludeDirectMessages { get; init; } = true;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
// Get channel metadata
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
|
||||
// Aggregate channels from all guilds
|
||||
await foreach (var guild in GetDiscordClient().GetUserGuildsAsync())
|
||||
await foreach (var guild in Discord.GetUserGuildsAsync())
|
||||
{
|
||||
// Skip DMs if instructed to
|
||||
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
|
||||
continue;
|
||||
|
||||
await foreach (var channel in GetDiscordClient().GetGuildChannelsAsync(guild.Id))
|
||||
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id))
|
||||
{
|
||||
channels.Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
await ExportMultipleAsync(console, channels);
|
||||
// Export
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("export", Description = "Export a channel.")]
|
||||
public class ExportChannelCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID.")]
|
||||
public Snowflake ChannelId { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
await ExportAsync(console, ChannelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
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;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("export", Description = "Export one or multiple channels.")]
|
||||
public class ExportChannelsCommand : ExportCommandBase
|
||||
{
|
||||
// TODO: change this to plural (breaking change)
|
||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
|
||||
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
// Get channel metadata
|
||||
await console.Output.WriteLineAsync("Fetching channel(s)...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
|
||||
foreach (var channelId in ChannelIds)
|
||||
{
|
||||
var channel = await Discord.GetChannelAsync(channelId);
|
||||
channels.Add(channel);
|
||||
}
|
||||
|
||||
// Export
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportdm", Description = "Export all direct message channels.")]
|
||||
public class ExportDirectMessagesCommand : ExportMultipleCommandBase
|
||||
public class ExportDirectMessagesCommand : ExportCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
await ExportMultipleAsync(console, channels);
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
// Get channel metadata
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
|
||||
// Export
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,28 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
||||
public class ExportGuildCommand : ExportMultipleCommandBase
|
||||
public class ExportGuildCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||
public Snowflake GuildId { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
await ExportMultipleAsync(console, channels);
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
// Get channel metadata
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
var channels = await Discord.GetGuildChannelsAsync(GuildId);
|
||||
|
||||
// Export
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
@@ -16,10 +17,23 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
|
||||
var channels = await Discord.GetGuildChannelsAsync(GuildId);
|
||||
|
||||
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||
foreach (var channel in channels.OrderBy(c => c.Category.Position).ThenBy(c => c.Name))
|
||||
{
|
||||
// Channel ID
|
||||
await console.Output.WriteAsync(channel.Id.ToString());
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
|
||||
// Channel category / name
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteAsync($"{channel.Category} / {channel.Name}");
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
@@ -13,10 +14,23 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id);
|
||||
|
||||
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
|
||||
foreach (var channel in channels.OrderBy(c => c.Name))
|
||||
{
|
||||
// Channel ID
|
||||
await console.Output.WriteAsync(channel.Id.ToString());
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
|
||||
// Channel category / name
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteAsync($"{channel.Category} / {channel.Name}");
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
@@ -12,10 +13,23 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
var guilds = await GetDiscordClient().GetUserGuildsAsync();
|
||||
var guilds = await Discord.GetUserGuildsAsync();
|
||||
|
||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
||||
console.Output.WriteLine($"{guild.Id} | {guild.Name}");
|
||||
{
|
||||
// Guild ID
|
||||
await console.Output.WriteAsync(guild.Id.ToString());
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
|
||||
// Guild name
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteAsync(guild.Name);
|
||||
|
||||
await console.Output.WriteLineAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
@@ -10,9 +11,9 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get user token:")
|
||||
);
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console.Output.WriteLine("To get user token:");
|
||||
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||
console.Output.WriteLine(" 3. Navigate to the Application tab");
|
||||
@@ -22,18 +23,18 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk.");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get bot token:")
|
||||
);
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console.Output.WriteLine("To get bot token:");
|
||||
|
||||
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 Copy");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get guild ID or guild channel ID:")
|
||||
);
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console.Output.WriteLine("To get guild ID or guild channel ID:");
|
||||
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Open Settings");
|
||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
||||
@@ -41,9 +42,9 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White, () =>
|
||||
console.Output.WriteLine("To get direct message channel ID:")
|
||||
);
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console.Output.WriteLine("To get direct message channel ID:");
|
||||
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Open the desired direct message channel");
|
||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
@@ -52,12 +53,11 @@ namespace DiscordChatExporter.Cli.Commands
|
||||
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.WithForegroundColor(ConsoleColor.White,
|
||||
() => console.Output.WriteLine("For more information, check out the wiki:")
|
||||
);
|
||||
console.WithForegroundColor(ConsoleColor.Blue,
|
||||
() => console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki")
|
||||
);
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console.Output.WriteLine("For more information, check out the wiki:");
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="1.6.0" />
|
||||
<PackageReference Include="CliFx" Version="2.0.4" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.40.0" />
|
||||
<PackageReference Include="Gress" Version="1.2.0" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Domain\DiscordChatExporter.Domain.csproj" />
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Utils.Extensions
|
||||
{
|
||||
internal static class ConsoleExtensions
|
||||
{
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) => AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output)
|
||||
}
|
||||
);
|
||||
|
||||
public static Progress CreateProgressTicker(this IConsole console) => console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
.AutoRefresh(true)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn {Alignment = Justify.Left},
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn()
|
||||
);
|
||||
|
||||
public static async ValueTask StartTaskAsync(
|
||||
this ProgressContext progressContext,
|
||||
string description,
|
||||
Func<ProgressTask, ValueTask> performOperationAsync)
|
||||
{
|
||||
var progressTask = progressContext.AddTask(
|
||||
// Don't recognize random square brackets as style tags
|
||||
Markup.Escape(description),
|
||||
new ProgressTaskSettings {MaxValue = 1}
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
await performOperationAsync(progressTask);
|
||||
}
|
||||
finally
|
||||
{
|
||||
progressTask.StopTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -1,9 +1,7 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public enum AuthTokenType { User, Bot }
|
||||
|
||||
public class AuthToken
|
||||
{
|
||||
public AuthTokenType Type { get; }
|
||||
@@ -16,7 +14,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
Value = value;
|
||||
}
|
||||
|
||||
public AuthenticationHeaderValue GetAuthorizationHeader() => Type switch
|
||||
public AuthenticationHeaderValue GetAuthenticationHeader() => Type switch
|
||||
{
|
||||
AuthTokenType.Bot => new AuthenticationHeaderValue("Bot", Value),
|
||||
_ => new AuthenticationHeaderValue(Value)
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public enum AuthTokenType
|
||||
{
|
||||
User,
|
||||
Bot
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -2,11 +2,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#attachment-object
|
||||
public partial class Attachment : IHasId
|
||||
@@ -32,7 +32,13 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public FileSize FileSize { get; }
|
||||
|
||||
public Attachment(Snowflake id, string url, string fileName, int? width, int? height, FileSize fileSize)
|
||||
public Attachment(
|
||||
Snowflake id,
|
||||
string url,
|
||||
string fileName,
|
||||
int? width,
|
||||
int? height,
|
||||
FileSize fileSize)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object
|
||||
public partial class Channel : IHasId
|
||||
{
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public ChannelType Type { get; }
|
||||
|
||||
public bool IsTextChannel => Type is
|
||||
ChannelType.GuildTextChat or
|
||||
ChannelType.DirectTextChat or
|
||||
ChannelType.DirectGroupTextChat or
|
||||
ChannelType.GuildNews or
|
||||
ChannelType.GuildStore;
|
||||
|
||||
public Snowflake GuildId { get; }
|
||||
|
||||
public ChannelCategory Category { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public int? Position { get; }
|
||||
|
||||
public string? Topic { get; }
|
||||
|
||||
public Channel(
|
||||
Snowflake id,
|
||||
ChannelType type,
|
||||
Snowflake guildId,
|
||||
ChannelCategory category,
|
||||
string name,
|
||||
int? position,
|
||||
string? topic)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
GuildId = guildId;
|
||||
Category = category;
|
||||
Name = name;
|
||||
Position = position;
|
||||
Topic = topic;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
private static ChannelCategory GetFallbackCategory(ChannelType channelType) => new(
|
||||
Snowflake.Zero,
|
||||
channelType switch
|
||||
{
|
||||
ChannelType.GuildTextChat => "Text",
|
||||
ChannelType.DirectTextChat => "Private",
|
||||
ChannelType.DirectGroupTextChat => "Group",
|
||||
ChannelType.GuildNews => "News",
|
||||
ChannelType.GuildStore => "Store",
|
||||
_ => "Default"
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
public static Channel Parse(JsonElement json, ChannelCategory? category = null, int? position = null)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var guildId = json.GetPropertyOrNull("guild_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
var topic = json.GetPropertyOrNull("topic")?.GetString();
|
||||
var type = (ChannelType) json.GetProperty("type").GetInt32();
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
json.GetPropertyOrNull("name")?.GetString() ??
|
||||
// DM channel
|
||||
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
|
||||
// Fallback
|
||||
id.ToString();
|
||||
|
||||
return new Channel(
|
||||
id,
|
||||
type,
|
||||
guildId ?? Guild.DirectMessages.Id,
|
||||
category ?? GetFallbackCategory(type),
|
||||
name,
|
||||
position ?? json.GetPropertyOrNull("position")?.GetInt32(),
|
||||
topic
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
public partial class ChannelCategory : IHasId
|
||||
{
|
||||
public Snowflake Id { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public int? Position { get; }
|
||||
|
||||
public ChannelCategory(Snowflake id, string name, int? position)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Position = position;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class ChannelCategory
|
||||
{
|
||||
public static ChannelCategory Parse(JsonElement json, int? position = null)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
|
||||
var name =
|
||||
json.GetPropertyOrNull("name")?.GetString() ??
|
||||
id.ToString();
|
||||
|
||||
return new ChannelCategory(
|
||||
id,
|
||||
name,
|
||||
position ?? json.GetPropertyOrNull("position")?.GetInt32()
|
||||
);
|
||||
}
|
||||
|
||||
public static ChannelCategory Empty { get; } = new(Snowflake.Zero, "<unknown category>", 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
// Order of enum fields needs to match the order in the docs.
|
||||
public enum ChannelType
|
||||
{
|
||||
GuildTextChat = 0,
|
||||
DirectTextChat,
|
||||
GuildVoiceChat,
|
||||
DirectGroupTextChat,
|
||||
GuildCategory,
|
||||
GuildNews,
|
||||
GuildStore
|
||||
}
|
||||
}
|
||||
+1
-15
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
||||
{
|
||||
// Loosely based on https://github.com/omar/ByteSize (MIT license)
|
||||
public readonly partial struct FileSize
|
||||
@@ -10,19 +10,11 @@ namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
public double TotalKiloBytes => TotalBytes / 1024.0;
|
||||
public double TotalMegaBytes => TotalKiloBytes / 1024.0;
|
||||
public double TotalGigaBytes => TotalMegaBytes / 1024.0;
|
||||
public double TotalTeraBytes => TotalGigaBytes / 1024.0;
|
||||
public double TotalPetaBytes => TotalTeraBytes / 1024.0;
|
||||
|
||||
public FileSize(long bytes) => TotalBytes = bytes;
|
||||
|
||||
private double GetLargestWholeNumberValue()
|
||||
{
|
||||
if (Math.Abs(TotalPetaBytes) >= 1)
|
||||
return TotalPetaBytes;
|
||||
|
||||
if (Math.Abs(TotalTeraBytes) >= 1)
|
||||
return TotalTeraBytes;
|
||||
|
||||
if (Math.Abs(TotalGigaBytes) >= 1)
|
||||
return TotalGigaBytes;
|
||||
|
||||
@@ -37,12 +29,6 @@ namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
|
||||
private string GetLargestWholeNumberSymbol()
|
||||
{
|
||||
if (Math.Abs(TotalPetaBytes) >= 1)
|
||||
return "PB";
|
||||
|
||||
if (Math.Abs(TotalTeraBytes) >= 1)
|
||||
return "TB";
|
||||
|
||||
if (Math.Abs(TotalGigaBytes) >= 1)
|
||||
return "GB";
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
||||
{
|
||||
public interface IHasId
|
||||
{
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models.Common
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
||||
{
|
||||
public partial class IdBasedEqualityComparer : IEqualityComparer<IHasId>
|
||||
{
|
||||
+5
-4
@@ -3,11 +3,10 @@ using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object
|
||||
public partial class Embed
|
||||
@@ -56,6 +55,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
Footer = footer;
|
||||
}
|
||||
|
||||
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() => YouTubeVideoEmbedProjection.TryResolve(this);
|
||||
|
||||
public override string ToString() => Title ?? "<untitled embed>";
|
||||
}
|
||||
|
||||
@@ -64,10 +65,10 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
public static Embed Parse(JsonElement json)
|
||||
{
|
||||
var title = json.GetPropertyOrNull("title")?.GetString();
|
||||
var description = json.GetPropertyOrNull("description")?.GetString();
|
||||
var url = json.GetPropertyOrNull("url")?.GetString();
|
||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
|
||||
var color = json.GetPropertyOrNull("color")?.GetInt32().Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
|
||||
var description = json.GetPropertyOrNull("description")?.GetString();
|
||||
|
||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||
public partial class EmbedAuthor
|
||||
@@ -12,11 +12,14 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public string? IconUrl { get; }
|
||||
|
||||
public EmbedAuthor(string? name, string? url, string? iconUrl)
|
||||
public string? IconProxyUrl { get; }
|
||||
|
||||
public EmbedAuthor(string? name, string? url, string? iconUrl, string? iconProxyUrl)
|
||||
{
|
||||
Name = name;
|
||||
Url = url;
|
||||
IconUrl = iconUrl;
|
||||
IconProxyUrl = iconProxyUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Name ?? "<unnamed author>";
|
||||
@@ -29,8 +32,9 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
var name = json.GetPropertyOrNull("name")?.GetString();
|
||||
var url = json.GetPropertyOrNull("url")?.GetString();
|
||||
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
|
||||
var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetString();
|
||||
|
||||
return new EmbedAuthor(name, url, iconUrl);
|
||||
return new EmbedAuthor(name, url, iconUrl, iconProxyUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||
public partial class EmbedField
|
||||
+7
-3
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||
public partial class EmbedFooter
|
||||
@@ -10,10 +10,13 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public string? IconUrl { get; }
|
||||
|
||||
public EmbedFooter(string text, string? iconUrl)
|
||||
public string? IconProxyUrl { get; }
|
||||
|
||||
public EmbedFooter(string text, string? iconUrl, string? iconProxyUrl)
|
||||
{
|
||||
Text = text;
|
||||
IconUrl = iconUrl;
|
||||
IconProxyUrl = iconProxyUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Text;
|
||||
@@ -25,8 +28,9 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
var text = json.GetProperty("text").GetString();
|
||||
var iconUrl = json.GetPropertyOrNull("icon_url")?.GetString();
|
||||
var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetString();
|
||||
|
||||
return new EmbedFooter(text, iconUrl);
|
||||
return new EmbedFooter(text, iconUrl, iconProxyUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -1,20 +1,23 @@
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||
public partial class EmbedImage
|
||||
{
|
||||
public string? Url { get; }
|
||||
|
||||
public string? ProxyUrl { get; }
|
||||
|
||||
public int? Width { get; }
|
||||
|
||||
public int? Height { get; }
|
||||
|
||||
public EmbedImage(string? url, int? width, int? height)
|
||||
public EmbedImage(string? url, string? proxyUrl, int? width, int? height)
|
||||
{
|
||||
Url = url;
|
||||
ProxyUrl = proxyUrl;
|
||||
Height = height;
|
||||
Width = width;
|
||||
}
|
||||
@@ -25,10 +28,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
public static EmbedImage Parse(JsonElement json)
|
||||
{
|
||||
var url = json.GetPropertyOrNull("url")?.GetString();
|
||||
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetString();
|
||||
var width = json.GetPropertyOrNull("width")?.GetInt32();
|
||||
var height = json.GetPropertyOrNull("height")?.GetInt32();
|
||||
|
||||
return new EmbedImage(url, width, height);
|
||||
return new EmbedImage(url, proxyUrl, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
||||
public partial class Emoji
|
||||
{
|
||||
// Only present on custom emoji
|
||||
public string? Id { get; }
|
||||
|
||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||
public string Name { get; }
|
||||
|
||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
||||
? Name
|
||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
|
||||
public bool IsAnimated { get; }
|
||||
|
||||
public string ImageUrl { get; }
|
||||
|
||||
public Emoji(string? id, string name, bool isAnimated, string imageUrl)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IsAnimated = isAnimated;
|
||||
ImageUrl = imageUrl;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
}
|
||||
|
||||
public partial class Emoji
|
||||
{
|
||||
private static string GetTwemojiName(string name) => name
|
||||
.GetRunes()
|
||||
// Variant selector rune is skipped in Twemoji names
|
||||
.Where(r => r.Value != 0xfe0f)
|
||||
.Select(r => r.Value.ToString("x"))
|
||||
.JoinToString("-");
|
||||
|
||||
public static string GetImageUrl(string? id, string name, bool isAnimated)
|
||||
{
|
||||
// Custom emoji
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return isAnimated
|
||||
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
||||
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||
}
|
||||
|
||||
// Standard emoji
|
||||
var twemojiName = GetTwemojiName(name);
|
||||
return $"https://twemoji.maxcdn.com/2/svg/{twemojiName}.svg";
|
||||
}
|
||||
|
||||
public static Emoji Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetPropertyOrNull("id")?.GetString();
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBoolean() ?? false;
|
||||
|
||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
||||
|
||||
return new Emoji(id, name, isAnimated, imageUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -1,8 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||
public partial class Guild : IHasId
|
||||
@@ -25,7 +25,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public static Guild DirectMessages { get; } = new(Snowflake.Zero, "Direct Messages", GetDefaultIconUrl());
|
||||
public static Guild DirectMessages { get; } = new(
|
||||
Snowflake.Zero,
|
||||
"Direct Messages",
|
||||
GetDefaultIconUrl()
|
||||
);
|
||||
|
||||
private static string GetDefaultIconUrl() =>
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
+12
-8
@@ -2,11 +2,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/guild#guild-member-object
|
||||
public partial class Member : IHasId
|
||||
@@ -19,10 +19,10 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public IReadOnlyList<Snowflake> RoleIds { get; }
|
||||
|
||||
public Member(User user, string? nick, IReadOnlyList<Snowflake> roleIds)
|
||||
public Member(User user, string nick, IReadOnlyList<Snowflake> roleIds)
|
||||
{
|
||||
User = user;
|
||||
Nick = nick ?? user.Name;
|
||||
Nick = nick;
|
||||
RoleIds = roleIds;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public partial class Member
|
||||
{
|
||||
public static Member CreateForUser(User user) => new(user, null, Array.Empty<Snowflake>());
|
||||
public static Member CreateForUser(User user) => new(
|
||||
user,
|
||||
user.Name,
|
||||
Array.Empty<Snowflake>()
|
||||
);
|
||||
|
||||
public static Member Parse(JsonElement json)
|
||||
{
|
||||
@@ -39,12 +43,12 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
var nick = json.GetPropertyOrNull("nick")?.GetString();
|
||||
|
||||
var roleIds =
|
||||
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString().Pipe(Snowflake.Parse)).ToArray() ??
|
||||
json.GetPropertyOrNull("roles")?.EnumerateArray().Select(j => j.GetString()).Select(Snowflake.Parse).ToArray() ??
|
||||
Array.Empty<Snowflake>();
|
||||
|
||||
return new Member(
|
||||
user,
|
||||
nick,
|
||||
nick ?? user.Name,
|
||||
roleIds
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -2,11 +2,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-types
|
||||
public enum MessageType
|
||||
@@ -100,7 +100,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
var type = (MessageType) json.GetProperty("type").GetInt32();
|
||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse);
|
||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Message.Parse);
|
||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||
|
||||
var content = type switch
|
||||
{
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||
public partial class MessageReference
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/channel#reaction-object
|
||||
public partial class Reaction
|
||||
@@ -23,8 +23,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
public static Reaction Parse(JsonElement json)
|
||||
{
|
||||
var count = json.GetProperty("count").GetInt32();
|
||||
var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse);
|
||||
var count = json.GetProperty("count").GetInt32();
|
||||
|
||||
return new Reaction(emoji, count);
|
||||
}
|
||||
+10
-6
@@ -1,11 +1,10 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/topics/permissions#role-object
|
||||
public partial class Role : IHasId
|
||||
@@ -18,7 +17,11 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public Color? Color { get; }
|
||||
|
||||
public Role(Snowflake id, string name, int position, Color? color)
|
||||
public Role(
|
||||
Snowflake id,
|
||||
string name,
|
||||
int position,
|
||||
Color? color)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
@@ -37,7 +40,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
var name = json.GetProperty("name").GetString();
|
||||
var position = json.GetProperty("position").GetInt32();
|
||||
|
||||
var color = json.GetPropertyOrNull("color")?
|
||||
var color = json
|
||||
.GetPropertyOrNull("color")?
|
||||
.GetInt32()
|
||||
.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha()
|
||||
+15
-8
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/user#user-object
|
||||
public partial class User : IHasId
|
||||
@@ -15,13 +15,20 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public int Discriminator { get; }
|
||||
|
||||
public string DiscriminatorFormatted => $"{Discriminator:0000}";
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string FullName => $"{Name}#{Discriminator:0000}";
|
||||
public string FullName => $"{Name}#{DiscriminatorFormatted}";
|
||||
|
||||
public string AvatarUrl { get; }
|
||||
|
||||
public User(Snowflake id, bool isBot, int discriminator, string name, string avatarUrl)
|
||||
public User(
|
||||
Snowflake id,
|
||||
bool isBot,
|
||||
int discriminator,
|
||||
string name,
|
||||
string avatarUrl)
|
||||
{
|
||||
Id = id;
|
||||
IsBot = isBot;
|
||||
@@ -42,19 +49,19 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
{
|
||||
// Animated
|
||||
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif?size=40";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png?size=40";
|
||||
}
|
||||
|
||||
public static User Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetString().Pipe(Snowflake.Parse);
|
||||
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
|
||||
var discriminator = json.GetProperty("discriminator").GetString().Pipe(int.Parse);
|
||||
var name = json.GetProperty("username").GetString();
|
||||
var avatarHash = json.GetProperty("avatar").GetString();
|
||||
var isBot = json.GetPropertyOrNull("bot")?.GetBoolean() ?? false;
|
||||
|
||||
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
||||
? GetAvatarUrl(id, avatarHash)
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
public partial class YouTubeVideoEmbedProjection
|
||||
{
|
||||
public string VideoId { get; }
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public YouTubeVideoEmbedProjection(string videoId, string url)
|
||||
{
|
||||
VideoId = videoId;
|
||||
Url = url;
|
||||
}
|
||||
|
||||
public override string ToString() => Url;
|
||||
}
|
||||
|
||||
public partial class YouTubeVideoEmbedProjection
|
||||
{
|
||||
// Adapted from YoutubeExplode
|
||||
// https://github.com/Tyrrrz/YoutubeExplode/blob/5be164be20019783913f76fcc98f18c65aebe9f0/YoutubeExplode/Videos/VideoId.cs#L34-L64
|
||||
private static string? TryParseVideoId(string embedUrl)
|
||||
{
|
||||
// Regular URL
|
||||
// https://www.youtube.com/watch?v=yIVRs6YSbOM
|
||||
var regularMatch = Regex.Match(embedUrl, @"youtube\..+?/watch.*?v=(.*?)(?:&|/|$)").Groups[1].Value;
|
||||
if (!string.IsNullOrWhiteSpace(regularMatch))
|
||||
return regularMatch;
|
||||
|
||||
// Short URL
|
||||
// https://youtu.be/yIVRs6YSbOM
|
||||
var shortMatch = Regex.Match(embedUrl, @"youtu\.be/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
||||
if (!string.IsNullOrWhiteSpace(shortMatch))
|
||||
return shortMatch;
|
||||
|
||||
// Embed URL
|
||||
// https://www.youtube.com/embed/yIVRs6YSbOM
|
||||
var embedMatch = Regex.Match(embedUrl, @"youtube\..+?/embed/(.*?)(?:\?|&|/|$)").Groups[1].Value;
|
||||
if (!string.IsNullOrWhiteSpace(embedMatch))
|
||||
return embedMatch;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(embed.Url))
|
||||
return null;
|
||||
|
||||
var videoId = TryParseVideoId(embed.Url);
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
return null;
|
||||
|
||||
var url = $"https://www.youtube.com/embed/{videoId}";
|
||||
|
||||
return new YouTubeVideoEmbedProjection(videoId, url);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-28
@@ -5,14 +5,14 @@ using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Http;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public class DiscordClient
|
||||
{
|
||||
@@ -28,13 +28,15 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
}
|
||||
|
||||
public DiscordClient(AuthToken token)
|
||||
: this(Http.Client, token) {}
|
||||
: this(Http.Client, token)
|
||||
{
|
||||
}
|
||||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(string url) =>
|
||||
await Http.ResponsePolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
request.Headers.Authorization = _token.GetAuthorizationHeader();
|
||||
request.Headers.Authorization = _token.GetAuthenticationHeader();
|
||||
|
||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
});
|
||||
@@ -63,7 +65,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
|
||||
return response.IsSuccessStatusCode
|
||||
? await response.Content.ReadAsJsonAsync()
|
||||
: (JsonElement?) null;
|
||||
: null;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Guild> GetUserGuildsAsync()
|
||||
@@ -117,26 +119,35 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels");
|
||||
|
||||
var categories = response
|
||||
var responseOrdered = response
|
||||
.EnumerateArray()
|
||||
.ToDictionary(
|
||||
j => j.GetProperty("id").GetString(),
|
||||
j => j.GetProperty("name").GetString()
|
||||
);
|
||||
.OrderBy(j => j.GetProperty("position").GetInt32())
|
||||
.ThenBy(j => Snowflake.Parse(j.GetProperty("id").GetString()))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channelJson in response.EnumerateArray())
|
||||
var categories = responseOrdered
|
||||
.Where(j => j.GetProperty("type").GetInt32() == (int) ChannelType.GuildCategory)
|
||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
||||
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal);
|
||||
|
||||
var position = 0;
|
||||
|
||||
foreach (var channelJson in responseOrdered)
|
||||
{
|
||||
var parentId = channelJson.GetPropertyOrNull("parent_id")?.GetString();
|
||||
|
||||
var category = !string.IsNullOrWhiteSpace(parentId)
|
||||
? categories.GetValueOrDefault(parentId)
|
||||
: null;
|
||||
|
||||
var channel = Channel.Parse(channelJson, category);
|
||||
var channel = Channel.Parse(channelJson, category, position);
|
||||
|
||||
// Skip non-text channels
|
||||
// We are only interested in text channels
|
||||
if (!channel.IsTextChannel)
|
||||
continue;
|
||||
|
||||
position++;
|
||||
|
||||
yield return channel;
|
||||
}
|
||||
}
|
||||
@@ -162,10 +173,19 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
return response?.Pipe(Member.Parse);
|
||||
}
|
||||
|
||||
private async ValueTask<string> GetChannelCategoryAsync(Snowflake channelParentId)
|
||||
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(Snowflake channelId)
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"channels/{channelParentId}");
|
||||
return response.GetProperty("name").GetString();
|
||||
try
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"channels/{channelId}");
|
||||
return ChannelCategory.Parse(response);
|
||||
}
|
||||
// In some cases, the Discord API returns an empty body when requesting channel category.
|
||||
// Instead, we use an empty channel category as a fallback.
|
||||
catch (DiscordChatExporterException)
|
||||
{
|
||||
return ChannelCategory.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
|
||||
@@ -174,7 +194,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
|
||||
var parentId = response.GetPropertyOrNull("parent_id")?.GetString().Pipe(Snowflake.Parse);
|
||||
|
||||
var category = parentId != null
|
||||
var category = parentId is not null
|
||||
? await GetChannelCategoryAsync(parentId.Value)
|
||||
: null;
|
||||
|
||||
@@ -200,11 +220,11 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
IProgress<double>? progress = null)
|
||||
{
|
||||
// Get the last message in the specified range.
|
||||
// This snapshots the boundaries, which means that messages posted after the exported started
|
||||
// This snapshots the boundaries, which means that messages posted after the export started
|
||||
// will not appear in the output.
|
||||
// Additionally, it provides the date of the last message, which is used to calculate progress.
|
||||
var lastMessage = await TryGetLastMessageAsync(channelId, before);
|
||||
if (lastMessage == null || lastMessage.Timestamp < after?.ToDate())
|
||||
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
|
||||
yield break;
|
||||
|
||||
// Keep track of first message in range in order to calculate progress
|
||||
@@ -239,11 +259,23 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
if (message.Timestamp > lastMessage.Timestamp)
|
||||
yield break;
|
||||
|
||||
// Report progress based on the duration of parsed messages divided by total
|
||||
progress?.Report(
|
||||
(message.Timestamp - firstMessage.Timestamp) /
|
||||
(lastMessage.Timestamp - firstMessage.Timestamp)
|
||||
);
|
||||
// Report progress based on the duration of exported messages divided by total
|
||||
if (progress is not null)
|
||||
{
|
||||
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||
|
||||
if (totalDuration > TimeSpan.Zero)
|
||||
{
|
||||
progress.Report(exportedDuration / totalDuration);
|
||||
}
|
||||
// Avoid division by zero if all messages have the exact same timestamp
|
||||
// (which may be the case if there's only one message in the channel)
|
||||
else
|
||||
{
|
||||
progress.Report(1);
|
||||
}
|
||||
}
|
||||
|
||||
yield return message;
|
||||
currentAfter = message.Id;
|
||||
@@ -251,4 +283,4 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-7
@@ -2,7 +2,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public readonly partial struct Snowflake
|
||||
{
|
||||
@@ -10,8 +10,9 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
|
||||
public Snowflake(ulong value) => Value = value;
|
||||
|
||||
public DateTimeOffset ToDate() =>
|
||||
DateTimeOffset.FromUnixTimeMilliseconds((long) ((Value >> 22) + 1420070400000UL)).ToLocalTime();
|
||||
public DateTimeOffset ToDate() => DateTimeOffset.FromUnixTimeMilliseconds(
|
||||
(long) ((Value >> 22) + 1420070400000UL)
|
||||
).ToLocalTime();
|
||||
|
||||
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -32,7 +33,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
return null;
|
||||
|
||||
// As number
|
||||
if (Regex.IsMatch(str, @"^\d{15,}$") &&
|
||||
if (Regex.IsMatch(str, @"^\d+$") &&
|
||||
ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value))
|
||||
{
|
||||
return new Snowflake(value);
|
||||
@@ -48,14 +49,16 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
}
|
||||
|
||||
public static Snowflake Parse(string str, IFormatProvider? formatProvider) =>
|
||||
TryParse(str, formatProvider) ?? throw new FormatException($"Invalid snowflake: {str}.");
|
||||
TryParse(str, formatProvider) ?? throw new FormatException($"Invalid snowflake '{str}'.");
|
||||
|
||||
public static Snowflake Parse(string str) => Parse(str, null);
|
||||
}
|
||||
|
||||
public partial struct Snowflake : IEquatable<Snowflake>
|
||||
public partial struct Snowflake : IComparable<Snowflake>, IEquatable<Snowflake>
|
||||
{
|
||||
public bool Equals(Snowflake other) => Value == other.Value;
|
||||
public int CompareTo(Snowflake other) => Value.CompareTo(other.Value);
|
||||
|
||||
public bool Equals(Snowflake other) => CompareTo(other) == 0;
|
||||
|
||||
public override bool Equals(object? obj) => obj is Snowflake other && Equals(other);
|
||||
|
||||
+3
-3
@@ -5,9 +5,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonExtensions" Version="1.0.1" />
|
||||
<PackageReference Include="MiniRazor" Version="2.0.3" />
|
||||
<PackageReference Include="Polly" Version="7.2.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.1.0" />
|
||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.1.2" />
|
||||
<PackageReference Include="Polly" Version="7.2.2" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
+11
-21
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exceptions
|
||||
namespace DiscordChatExporter.Core.Exceptions
|
||||
{
|
||||
public partial class DiscordChatExporterException : Exception
|
||||
{
|
||||
@@ -21,35 +21,25 @@ namespace DiscordChatExporter.Domain.Exceptions
|
||||
var message = $@"
|
||||
Failed to perform an HTTP request.
|
||||
|
||||
[Request]
|
||||
{response.RequestMessage}
|
||||
|
||||
[Response]
|
||||
{response}";
|
||||
|
||||
return new DiscordChatExporterException(message.Trim(), true);
|
||||
}
|
||||
|
||||
internal static DiscordChatExporterException Unauthorized()
|
||||
{
|
||||
const string message = "Authentication token is invalid.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
internal static DiscordChatExporterException Unauthorized() =>
|
||||
new("Authentication token is invalid.");
|
||||
|
||||
internal static DiscordChatExporterException Forbidden()
|
||||
{
|
||||
const string message = "Access is forbidden.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
internal static DiscordChatExporterException Forbidden() =>
|
||||
new("Access is forbidden.");
|
||||
|
||||
internal static DiscordChatExporterException NotFound()
|
||||
{
|
||||
const string message = "Requested resource does not exist.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
internal static DiscordChatExporterException NotFound() =>
|
||||
new("Requested resource does not exist.");
|
||||
|
||||
internal static DiscordChatExporterException ChannelIsEmpty()
|
||||
{
|
||||
var message = $"No messages for the specified period.";
|
||||
return new DiscordChatExporterException(message);
|
||||
}
|
||||
internal static DiscordChatExporterException ChannelIsEmpty() =>
|
||||
new("No messages found for the specified period.");
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -2,13 +2,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Discord.Models.Common;
|
||||
using DiscordChatExporter.Domain.Exceptions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
namespace DiscordChatExporter.Core.Exporting
|
||||
{
|
||||
public class ChannelExporter
|
||||
{
|
||||
+7
-7
@@ -5,12 +5,12 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
namespace DiscordChatExporter.Core.Exporting
|
||||
{
|
||||
internal class ExportContext
|
||||
{
|
||||
@@ -57,7 +57,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
||||
|
||||
return roles?
|
||||
.Where(r => r.Color != null)
|
||||
.Where(r => r.Color is not null)
|
||||
.OrderByDescending(r => r.Position)
|
||||
.Select(r => r.Color)
|
||||
.FirstOrDefault();
|
||||
@@ -79,7 +79,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
: filePath;
|
||||
|
||||
// HACK: for HTML, we need to format the URL properly
|
||||
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
|
||||
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
||||
{
|
||||
// Need to escape each path segment while keeping the directory separators intact
|
||||
return relativeFilePath
|
||||
@@ -93,7 +93,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
// Try to catch only exceptions related to failed HTTP requests
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
||||
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
|
||||
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
// TODO: add logging so we can be more liberal with catching exceptions
|
||||
// We don't want this to crash the exporting process in case of failure
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
namespace DiscordChatExporter.Core.Exporting
|
||||
{
|
||||
public enum ExportFormat
|
||||
{
|
||||
+34
-11
@@ -1,10 +1,13 @@
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
namespace DiscordChatExporter.Core.Exporting
|
||||
{
|
||||
public partial class ExportRequest
|
||||
{
|
||||
@@ -26,7 +29,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
public Snowflake? Before { get; }
|
||||
|
||||
public int? PartitionLimit { get; }
|
||||
public PartitionLimit PartitionLimit { get; }
|
||||
|
||||
public bool ShouldDownloadMedia { get; }
|
||||
|
||||
@@ -41,7 +44,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
ExportFormat format,
|
||||
Snowflake? after,
|
||||
Snowflake? before,
|
||||
int? partitionLimit,
|
||||
PartitionLimit partitionLimit,
|
||||
bool shouldDownloadMedia,
|
||||
bool shouldReuseMedia,
|
||||
string dateFormat)
|
||||
@@ -81,6 +84,26 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null)
|
||||
{
|
||||
|
||||
// Formats path
|
||||
outputPath = Regex.Replace(outputPath, "%.", m =>
|
||||
PathEx.EscapePath(m.Value switch
|
||||
{
|
||||
"%g" => guild.Id.ToString(),
|
||||
"%G" => guild.Name,
|
||||
"%t" => channel.Category.Id.ToString(),
|
||||
"%T" => channel.Category.Name,
|
||||
"%c" => channel.Id.ToString(),
|
||||
"%C" => channel.Name,
|
||||
"%p" => channel.Position?.ToString() ?? "0",
|
||||
"%P" => channel.Category.Position?.ToString() ?? "0",
|
||||
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
|
||||
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
|
||||
"%%" => "%",
|
||||
_ => m.Value
|
||||
})
|
||||
);
|
||||
|
||||
// Output is a directory
|
||||
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
|
||||
{
|
||||
@@ -102,20 +125,20 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
// Guild and channel names
|
||||
buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]");
|
||||
buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]");
|
||||
|
||||
// Date range
|
||||
if (after != null || before != null)
|
||||
if (after is not null || before is not null)
|
||||
{
|
||||
buffer.Append(" (");
|
||||
|
||||
// Both 'after' and 'before' are set
|
||||
if (after != null && before != null)
|
||||
if (after is not null && before is not null)
|
||||
{
|
||||
buffer.Append($"{after?.ToDate():yyyy-MM-dd} to {before?.ToDate():yyyy-MM-dd}");
|
||||
}
|
||||
// Only 'after' is set
|
||||
else if (after != null)
|
||||
else if (after is not null)
|
||||
{
|
||||
buffer.Append($"after {after?.ToDate():yyyy-MM-dd}");
|
||||
}
|
||||
+25
-16
@@ -7,11 +7,10 @@ using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Domain.Utilities;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
namespace DiscordChatExporter.Core.Exporting
|
||||
{
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
@@ -46,7 +45,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
Directory.CreateDirectory(_workingDirPath);
|
||||
|
||||
// This catches IOExceptions which is dangerous as we're working also with files
|
||||
// This retries on IOExceptions which is dangerous as we're also working with files
|
||||
await Http.ExceptionPolicy.ExecuteAsync(async () =>
|
||||
{
|
||||
// Download the file
|
||||
@@ -57,17 +56,27 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
}
|
||||
|
||||
// Try to set the file date according to the last-modified header
|
||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||
? date
|
||||
: (DateTimeOffset?) null
|
||||
);
|
||||
|
||||
if (lastModified != null)
|
||||
try
|
||||
{
|
||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
|
||||
? date
|
||||
: (DateTimeOffset?) null
|
||||
);
|
||||
|
||||
if (lastModified is not null)
|
||||
{
|
||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// This can apparently fail for some reason.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||
// Updating file dates is not a critical task, so we'll just
|
||||
// ignore exceptions thrown here.
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,4 +112,4 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
return PathEx.EscapePath(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-17
@@ -1,16 +1,15 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Writers;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
namespace DiscordChatExporter.Core.Exporting
|
||||
{
|
||||
internal partial class MessageExporter : IAsyncDisposable
|
||||
{
|
||||
private readonly ExportContext _context;
|
||||
|
||||
private long _messageCount;
|
||||
private int _partitionIndex;
|
||||
private MessageWriter? _writer;
|
||||
|
||||
@@ -19,15 +18,9 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
_context = context;
|
||||
}
|
||||
|
||||
private bool IsPartitionLimitReached() =>
|
||||
_messageCount > 0 &&
|
||||
_context.Request.PartitionLimit != null &&
|
||||
_context.Request.PartitionLimit != 0 &&
|
||||
_messageCount % _context.Request.PartitionLimit == 0;
|
||||
|
||||
private async ValueTask ResetWriterAsync()
|
||||
{
|
||||
if (_writer != null)
|
||||
if (_writer is not null)
|
||||
{
|
||||
await _writer.WritePostambleAsync();
|
||||
await _writer.DisposeAsync();
|
||||
@@ -38,14 +31,15 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
private async ValueTask<MessageWriter> GetWriterAsync()
|
||||
{
|
||||
// Ensure partition limit has not been exceeded
|
||||
if (IsPartitionLimitReached())
|
||||
if (_writer is not null &&
|
||||
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten))
|
||||
{
|
||||
await ResetWriterAsync();
|
||||
_partitionIndex++;
|
||||
}
|
||||
|
||||
// Writer is still valid - return
|
||||
if (_writer != null)
|
||||
if (_writer is not null)
|
||||
return _writer;
|
||||
|
||||
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex);
|
||||
@@ -64,7 +58,6 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
var writer = await GetWriterAsync();
|
||||
await writer.WriteMessageAsync(message);
|
||||
_messageCount++;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
||||
@@ -72,9 +65,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
internal partial class MessageExporter
|
||||
{
|
||||
private static string GetPartitionFilePath(
|
||||
string baseFilePath,
|
||||
int partitionIndex)
|
||||
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
|
||||
{
|
||||
// First partition - don't change file name
|
||||
if (partitionIndex <= 0)
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
||||
{
|
||||
public class FileSizePartitionLimit : PartitionLimit
|
||||
{
|
||||
private readonly long _limit;
|
||||
|
||||
public FileSizePartitionLimit(long limit) => _limit = limit;
|
||||
|
||||
public override bool IsReached(long messagesWritten, long bytesWritten) =>
|
||||
bytesWritten >= _limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
||||
{
|
||||
public class MessageCountPartitionLimit : PartitionLimit
|
||||
{
|
||||
private readonly long _limit;
|
||||
|
||||
public MessageCountPartitionLimit(long limit) => _limit = limit;
|
||||
|
||||
public override bool IsReached(long messagesWritten, long bytesWritten) =>
|
||||
messagesWritten >= _limit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
||||
{
|
||||
public class NullPartitionLimit : PartitionLimit
|
||||
{
|
||||
public static NullPartitionLimit Instance { get; } = new();
|
||||
|
||||
public override bool IsReached(long messagesWritten, long bytesWritten) => false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting.Partitioning
|
||||
{
|
||||
public abstract partial class PartitionLimit
|
||||
{
|
||||
public abstract bool IsReached(long messagesWritten, long bytesWritten);
|
||||
}
|
||||
|
||||
public partial class PartitionLimit
|
||||
{
|
||||
private static long? TryParseFileSizeBytes(string value, IFormatProvider? formatProvider = null)
|
||||
{
|
||||
var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase);
|
||||
|
||||
// Number part
|
||||
if (!double.TryParse(
|
||||
match.Groups[1].Value,
|
||||
NumberStyles.Float,
|
||||
formatProvider,
|
||||
out var number))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Magnitude part
|
||||
var magnitude = match.Groups[2].Value.ToUpperInvariant() switch
|
||||
{
|
||||
"G" => 1_000_000_000,
|
||||
"M" => 1_000_000,
|
||||
"K" => 1_000,
|
||||
"" => 1,
|
||||
_ => -1
|
||||
};
|
||||
|
||||
if (magnitude < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (long) (number * magnitude);
|
||||
}
|
||||
|
||||
public static PartitionLimit? TryParse(string value, IFormatProvider? formatProvider = null)
|
||||
{
|
||||
var fileSizeLimit = TryParseFileSizeBytes(value, formatProvider);
|
||||
if (fileSizeLimit is not null)
|
||||
return new FileSizePartitionLimit(fileSizeLimit.Value);
|
||||
|
||||
if (int.TryParse(value, NumberStyles.Integer, formatProvider, out var messageCountLimit))
|
||||
return new MessageCountPartitionLimit(messageCountLimit);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static PartitionLimit Parse(string value, IFormatProvider? formatProvider = null) =>
|
||||
TryParse(value, formatProvider) ?? throw new FormatException($"Invalid partition limit '{value}'.");
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -2,11 +2,11 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
{
|
||||
internal partial class CsvMessageWriter : MessageWriter
|
||||
{
|
||||
@@ -58,6 +58,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
public override async ValueTask WriteMessageAsync(Message message)
|
||||
{
|
||||
await base.WriteMessageAsync(message);
|
||||
|
||||
// Author ID
|
||||
await _writer.WriteAsync(CsvEncode(message.Author.Id.ToString()));
|
||||
await _writer.WriteAsync(',');
|
||||
+24
-7
@@ -146,8 +146,8 @@ img {
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
width: 1.325em;
|
||||
height: 1.325em;
|
||||
margin: 0 0.06em;
|
||||
vertical-align: -0.4em;
|
||||
}
|
||||
@@ -244,14 +244,21 @@ img {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chatlog__reference-link:hover {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chatlog__reference-content > * {
|
||||
display: inline;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chatlog__reference-content > a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chatlog__reference-content > .emoji {
|
||||
width: 1.325em;
|
||||
height: 1.325em;
|
||||
}
|
||||
|
||||
.chatlog__reference-edited-timestamp {
|
||||
@@ -480,10 +487,20 @@ img {
|
||||
}
|
||||
|
||||
.chatlog__embed-footer-text {
|
||||
vertical-align: middle;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chatlog__embed-youtube-container {
|
||||
margin-top: 0.6em;
|
||||
}
|
||||
|
||||
.chatlog__embed-youtube {
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__reactions {
|
||||
display: flex;
|
||||
}
|
||||
@@ -510,7 +527,7 @@ img {
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.3;
|
||||
background: #7289da;
|
||||
background: #5865F2;
|
||||
color: #ffffff;
|
||||
font-size: 0.625em;
|
||||
font-weight: 500;
|
||||
+6
-6
@@ -6,7 +6,7 @@ body {
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0096cf;
|
||||
color: #00aff4;
|
||||
}
|
||||
|
||||
.spoiler-text {
|
||||
@@ -34,7 +34,7 @@ a {
|
||||
color: #b9bbbe !important;
|
||||
}
|
||||
|
||||
/* === Preamble === */
|
||||
/* Preamble */
|
||||
|
||||
.preamble__entry {
|
||||
color: #ffffff;
|
||||
@@ -92,7 +92,7 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__embed-color-pill--default {
|
||||
background-color: rgba(79, 84, 92, 1);
|
||||
background-color: #202225;
|
||||
}
|
||||
|
||||
.chatlog__embed-content-container {
|
||||
@@ -113,7 +113,7 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__embed-description {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: #dcddde;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-name {
|
||||
@@ -121,11 +121,11 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__embed-field-value {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: #dcddde;
|
||||
}
|
||||
|
||||
.chatlog__embed-footer {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
color: #dcddde;
|
||||
}
|
||||
|
||||
.chatlog__reaction {
|
||||
+4
-4
@@ -7,7 +7,7 @@ body {
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00b0f4;
|
||||
color: #0068e0;
|
||||
}
|
||||
|
||||
.spoiler-text {
|
||||
@@ -115,7 +115,7 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__embed-description {
|
||||
color: #737f8d;
|
||||
color: #2e3338;
|
||||
}
|
||||
|
||||
.chatlog__embed-field-name {
|
||||
@@ -123,11 +123,11 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__embed-field-value {
|
||||
color: #737f8d;
|
||||
color: #2e3338;
|
||||
}
|
||||
|
||||
.chatlog__embed-footer {
|
||||
color: rgba(79, 83, 91, 0.6);
|
||||
color: #2e3338;
|
||||
}
|
||||
|
||||
.chatlog__reaction {
|
||||
+6
-2
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||
{
|
||||
// Used for grouping contiguous messages in HTML export
|
||||
internal partial class MessageGroup
|
||||
@@ -36,9 +36,13 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
internal partial class MessageGroup
|
||||
{
|
||||
public static bool CanJoin(Message message1, Message message2) =>
|
||||
// Must be from the same author
|
||||
message1.Author.Id == message2.Author.Id &&
|
||||
// Author's name must not have changed between messages
|
||||
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
||||
// Duration between messages must be 7 minutes or less
|
||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
|
||||
// Other message must not be a reply
|
||||
message2.Reference is null;
|
||||
|
||||
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
||||
+39
-31
@@ -1,8 +1,8 @@
|
||||
@using System
|
||||
@using System.Linq
|
||||
@using System.Threading.Tasks
|
||||
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.MessageGroupTemplateContext>
|
||||
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Core.Exporting.Writers.Html.MessageGroupTemplateContext>
|
||||
|
||||
@{
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
||||
|
||||
var userColorStyle = userColor != null
|
||||
var userColorStyle = userColor is not null
|
||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
||||
: null;
|
||||
|
||||
@@ -25,19 +25,19 @@
|
||||
? Model.MessageGroup.Author.Name
|
||||
: userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
||||
|
||||
var referencedUserMember = Model.MessageGroup.ReferencedMessage != null
|
||||
var referencedUserMember = Model.MessageGroup.ReferencedMessage is not null
|
||||
? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id)
|
||||
: null;
|
||||
|
||||
var referencedUserColor = Model.MessageGroup.ReferencedMessage != null
|
||||
var referencedUserColor = Model.MessageGroup.ReferencedMessage is not null
|
||||
? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id)
|
||||
: null;
|
||||
|
||||
var referencedUserColorStyle = referencedUserColor != null
|
||||
var referencedUserColorStyle = referencedUserColor is not null
|
||||
? $"color: rgb({referencedUserColor?.R},{referencedUserColor?.G},{referencedUserColor?.B})"
|
||||
: null;
|
||||
|
||||
var referencedUserNick = Model.MessageGroup.ReferencedMessage != null
|
||||
var referencedUserNick = Model.MessageGroup.ReferencedMessage is not null
|
||||
? Model.MessageGroup.ReferencedMessage.Author.IsBot
|
||||
? Model.MessageGroup.ReferencedMessage.Author.Name
|
||||
: referencedUserMember?.Nick ?? Model.MessageGroup.ReferencedMessage.Author.Name
|
||||
@@ -45,16 +45,16 @@
|
||||
}
|
||||
|
||||
<div class="chatlog__message-group">
|
||||
@if (Model.MessageGroup.Reference != null)
|
||||
@if (Model.MessageGroup.Reference is not null)
|
||||
{
|
||||
<div class="chatlog__reference-symbol">
|
||||
</div>
|
||||
<div class="chatlog__reference">
|
||||
@if (Model.MessageGroup.ReferencedMessage != null)
|
||||
@if (Model.MessageGroup.ReferencedMessage is not null)
|
||||
{
|
||||
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar">
|
||||
<span class="chatlog__reference-name" title="@Model.MessageGroup.ReferencedMessage.Author.FullName" style="@referencedUserColorStyle">@referencedUserNick</span>
|
||||
<a class="chatlog__reference-link" href="#" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
|
||||
<div class="chatlog__reference-link" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
|
||||
<span class="chatlog__reference-content">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content))
|
||||
{
|
||||
@@ -66,11 +66,11 @@
|
||||
}
|
||||
</span>
|
||||
|
||||
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp != null)
|
||||
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp is not null)
|
||||
{
|
||||
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(Model.MessageGroup.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -97,14 +97,14 @@
|
||||
{
|
||||
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
|
||||
|
||||
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
|
||||
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp != null)
|
||||
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id" title="Message sent: @FormatDate(message.Timestamp)">
|
||||
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
|
||||
{
|
||||
<div class="chatlog__content">
|
||||
<div class="markdown">
|
||||
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
|
||||
|
||||
@if (message.EditedTimestamp != null)
|
||||
@if (message.EditedTimestamp is not null)
|
||||
{
|
||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||
}
|
||||
@@ -161,8 +161,10 @@
|
||||
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
var youTubeVideo = embed.TryGetYouTubeVideo();
|
||||
|
||||
<div class="chatlog__embed">
|
||||
@if (embed.Color != null)
|
||||
@if (embed.Color is not null)
|
||||
{
|
||||
var embedColorStyle = $"background-color: rgba({embed.Color?.R},{embed.Color?.G},{embed.Color?.B},{embed.Color?.A})";
|
||||
<div class="chatlog__embed-color-pill" style="@embedColorStyle"></div>
|
||||
@@ -175,12 +177,12 @@
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@if (embed.Author != null)
|
||||
@if (embed.Author is not null)
|
||||
{
|
||||
<div class="chatlog__embed-author">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconUrl)" alt="Author icon">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
@@ -215,7 +217,13 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
@if (youTubeVideo is not null)
|
||||
{
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideo.Url" width="400" height="225"></iframe>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
@@ -249,31 +257,31 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (embed.Thumbnail != null)
|
||||
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url) && youTubeVideo is null)
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.Url)" alt="Thumbnail">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (embed.Image != null)
|
||||
@if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url))
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.Url)" alt="Image">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (embed.Footer != null || embed.Timestamp != null)
|
||||
@if (embed.Footer is not null || embed.Timestamp is not null)
|
||||
{
|
||||
<div class="chatlog__embed-footer">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@@ -282,12 +290,12 @@
|
||||
@embed.Footer.Text
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@if (embed.Timestamp != null)
|
||||
@if (embed.Timestamp is not null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
@@ -303,8 +311,8 @@
|
||||
<div class="chatlog__reactions">
|
||||
@foreach (var reaction in message.Reactions)
|
||||
{
|
||||
<div class="chatlog__reaction">
|
||||
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" title="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
|
||||
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
||||
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
|
||||
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||
</div>
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||
{
|
||||
internal class MessageGroupTemplateContext
|
||||
{
|
||||
@@ -0,0 +1,12 @@
|
||||
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Core.Exporting.Writers.Html.PostambleTemplateContext>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="postamble">
|
||||
<div class="postamble__entry">Exported @Model.MessagesWritten.ToString("N0") message(s)</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||
{
|
||||
internal class PostambleTemplateContext
|
||||
{
|
||||
public ExportContext ExportContext { get; }
|
||||
|
||||
public long MessagesWritten { get; }
|
||||
|
||||
public PostambleTemplateContext(ExportContext exportContext, long messagesWritten)
|
||||
{
|
||||
ExportContext = exportContext;
|
||||
MessagesWritten = messagesWritten;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -1,15 +1,15 @@
|
||||
@using System
|
||||
@using System.Threading.Tasks
|
||||
@using Tyrrrz.Extensions
|
||||
@namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||
@inherits MiniRazor.TemplateBase<DiscordChatExporter.Core.Exporting.Writers.Html.PreambleTemplateContext>
|
||||
|
||||
@{
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
|
||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||
|
||||
string GetStyleSheet(string name) => Model.GetType().Assembly.GetManifestResourceString($"DiscordChatExporter.Domain.Exporting.Writers.Html.{name}.css");
|
||||
string GetStyleSheet(string name) => Model.GetType().Assembly.GetManifestResourceString($"DiscordChatExporter.Core.Exporting.Writers.Html.{name}.css");
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -78,18 +78,18 @@
|
||||
<div class="preamble__entry preamble__entry--small">@Model.ExportContext.Request.Channel.Topic</div>
|
||||
}
|
||||
|
||||
@if (Model.ExportContext.Request.After != null || Model.ExportContext.Request.Before != null)
|
||||
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
|
||||
{
|
||||
<div class="preamble__entry preamble__entry--small">
|
||||
@if (Model.ExportContext.Request.After != null && Model.ExportContext.Request.Before != null)
|
||||
@if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null)
|
||||
{
|
||||
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||
}
|
||||
else if (Model.ExportContext.Request.After != null)
|
||||
else if (Model.ExportContext.Request.After is not null)
|
||||
{
|
||||
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
|
||||
}
|
||||
else if (Model.ExportContext.Request.Before != null)
|
||||
else if (Model.ExportContext.Request.Before is not null)
|
||||
{
|
||||
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||
{
|
||||
internal class PreambleTemplateContext
|
||||
{
|
||||
public ExportContext ExportContext { get; }
|
||||
|
||||
public string ThemeName { get; }
|
||||
|
||||
public PreambleTemplateContext(ExportContext exportContext, string themeName)
|
||||
{
|
||||
ExportContext = exportContext;
|
||||
ThemeName = themeName;
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-10
@@ -2,10 +2,10 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.Html;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
{
|
||||
internal class HtmlMessageWriter : MessageWriter
|
||||
{
|
||||
@@ -14,8 +14,6 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
private readonly List<Message> _messageGroupBuffer = new();
|
||||
|
||||
private long _messageCount;
|
||||
|
||||
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
||||
: base(stream, context)
|
||||
{
|
||||
@@ -25,7 +23,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
public override async ValueTask WritePreambleAsync()
|
||||
{
|
||||
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
await PreambleTemplate.RenderAsync(templateContext)
|
||||
@@ -43,6 +41,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
public override async ValueTask WriteMessageAsync(Message message)
|
||||
{
|
||||
await base.WriteMessageAsync(message);
|
||||
|
||||
// If message group is empty or the given message can be grouped, buffer the given message
|
||||
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
|
||||
{
|
||||
@@ -56,9 +56,6 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_messageGroupBuffer.Clear();
|
||||
_messageGroupBuffer.Add(message);
|
||||
}
|
||||
|
||||
// Increment message count
|
||||
_messageCount++;
|
||||
}
|
||||
|
||||
public override async ValueTask WritePostambleAsync()
|
||||
@@ -67,7 +64,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
if (_messageGroupBuffer.Any())
|
||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
|
||||
|
||||
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
await PostambleTemplate.RenderAsync(templateContext)
|
||||
+28
-24
@@ -1,27 +1,28 @@
|
||||
using System.IO;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using System.Text.Encodings.Web;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Writing;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
{
|
||||
internal class JsonMessageWriter : MessageWriter
|
||||
{
|
||||
private readonly Utf8JsonWriter _writer;
|
||||
|
||||
private long _messageCount;
|
||||
|
||||
public JsonMessageWriter(Stream stream, ExportContext context)
|
||||
: base(stream, context)
|
||||
{
|
||||
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true
|
||||
Indented = true,
|
||||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteString("url", embedAuthor.Url);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconUrl));
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl));
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync();
|
||||
@@ -60,7 +61,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteStartObject("thumbnail");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embedThumbnail.Url))
|
||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.Url));
|
||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.ProxyUrl ?? embedThumbnail.Url));
|
||||
|
||||
_writer.WriteNumber("width", embedThumbnail.Width);
|
||||
_writer.WriteNumber("height", embedThumbnail.Height);
|
||||
@@ -74,7 +75,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteStartObject("image");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embedImage.Url))
|
||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.Url));
|
||||
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url));
|
||||
|
||||
_writer.WriteNumber("width", embedImage.Width);
|
||||
_writer.WriteNumber("height", embedImage.Height);
|
||||
@@ -90,7 +91,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteString("text", embedFooter.Text);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconUrl));
|
||||
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl));
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync();
|
||||
@@ -117,19 +118,19 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteString("timestamp", embed.Timestamp);
|
||||
_writer.WriteString("description", FormatMarkdown(embed.Description));
|
||||
|
||||
if (embed.Color != null)
|
||||
if (embed.Color is not null)
|
||||
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||
|
||||
if (embed.Author != null)
|
||||
if (embed.Author is not null)
|
||||
await WriteEmbedAuthorAsync(embed.Author);
|
||||
|
||||
if (embed.Thumbnail != null)
|
||||
if (embed.Thumbnail is not null)
|
||||
await WriteEmbedThumbnailAsync(embed.Thumbnail);
|
||||
|
||||
if (embed.Image != null)
|
||||
if (embed.Image is not null)
|
||||
await WriteEmbedImageAsync(embed.Image);
|
||||
|
||||
if (embed.Footer != null)
|
||||
if (embed.Footer is not null)
|
||||
await WriteEmbedFooterAsync(embed.Footer);
|
||||
|
||||
// Fields
|
||||
@@ -168,7 +169,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
_writer.WriteString("id", mentionedUser.Id.ToString());
|
||||
_writer.WriteString("name", mentionedUser.Name);
|
||||
_writer.WriteNumber("discriminator", mentionedUser.Discriminator);
|
||||
_writer.WriteString("discriminator", mentionedUser.DiscriminatorFormatted);
|
||||
_writer.WriteString("nickname", Context.TryGetMember(mentionedUser.Id)?.Nick ?? mentionedUser.Name);
|
||||
_writer.WriteBoolean("isBot", mentionedUser.IsBot);
|
||||
|
||||
@@ -192,7 +193,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteStartObject("channel");
|
||||
_writer.WriteString("id", Context.Request.Channel.Id.ToString());
|
||||
_writer.WriteString("type", Context.Request.Channel.Type.ToString());
|
||||
_writer.WriteString("category", Context.Request.Channel.Category);
|
||||
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString());
|
||||
_writer.WriteString("category", Context.Request.Channel.Category.Name);
|
||||
_writer.WriteString("name", Context.Request.Channel.Name);
|
||||
_writer.WriteString("topic", Context.Request.Channel.Topic);
|
||||
_writer.WriteEndObject();
|
||||
@@ -210,6 +212,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
public override async ValueTask WriteMessageAsync(Message message)
|
||||
{
|
||||
await base.WriteMessageAsync(message);
|
||||
|
||||
_writer.WriteStartObject();
|
||||
|
||||
// Metadata
|
||||
@@ -227,7 +231,9 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteStartObject("author");
|
||||
_writer.WriteString("id", message.Author.Id.ToString());
|
||||
_writer.WriteString("name", message.Author.Name);
|
||||
_writer.WriteString("discriminator", $"{message.Author.Discriminator:0000}");
|
||||
_writer.WriteString("discriminator", message.Author.DiscriminatorFormatted);
|
||||
_writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name);
|
||||
_writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
|
||||
_writer.WriteBoolean("isBot", message.Author.IsBot);
|
||||
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl));
|
||||
_writer.WriteEndObject();
|
||||
@@ -265,7 +271,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Message reference
|
||||
if (message.Reference != null)
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
_writer.WriteStartObject("reference");
|
||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||
@@ -276,8 +282,6 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync();
|
||||
|
||||
_messageCount++;
|
||||
}
|
||||
|
||||
public override async ValueTask WritePostambleAsync()
|
||||
@@ -285,7 +289,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
// Message array (end)
|
||||
_writer.WriteEndArray();
|
||||
|
||||
_writer.WriteNumber("messageCount", _messageCount);
|
||||
_writer.WriteNumber("messageCount", MessagesWritten);
|
||||
|
||||
// Root object (end)
|
||||
_writer.WriteEndObject();
|
||||
+12
-10
@@ -3,12 +3,13 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Markdown;
|
||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Ast;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
||||
{
|
||||
internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
||||
{
|
||||
@@ -76,6 +77,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
|
||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||
{
|
||||
var mentionId = Snowflake.TryParse(mention.Id);
|
||||
if (mention.Type == MentionType.Meta)
|
||||
{
|
||||
_buffer
|
||||
@@ -85,7 +87,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.User)
|
||||
{
|
||||
var member = _context.TryGetMember(Snowflake.Parse(mention.Id));
|
||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
||||
var fullName = member?.User.FullName ?? "Unknown";
|
||||
var nick = member?.Nick ?? "Unknown";
|
||||
|
||||
@@ -96,7 +98,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _context.TryGetChannel(Snowflake.Parse(mention.Id));
|
||||
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer
|
||||
@@ -106,11 +108,11 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
}
|
||||
else if (mention.Type == MentionType.Role)
|
||||
{
|
||||
var role = _context.TryGetRole(Snowflake.Parse(mention.Id));
|
||||
var role = mentionId?.Pipe(_context.TryGetRole);
|
||||
var name = role?.Name ?? "deleted-role";
|
||||
var color = role?.Color;
|
||||
|
||||
var style = color != null
|
||||
var style = color is not null
|
||||
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
|
||||
: "";
|
||||
|
||||
@@ -129,7 +131,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
var jumboClass = _isJumbo ? "emoji--large" : "";
|
||||
|
||||
_buffer
|
||||
.Append($"<img class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Name}\" src=\"{emojiImageUrl}\">");
|
||||
.Append($"<img class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
||||
|
||||
return base.VisitEmoji(emoji);
|
||||
}
|
||||
+9
-7
@@ -1,9 +1,10 @@
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Domain.Discord;
|
||||
using DiscordChatExporter.Domain.Markdown;
|
||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Ast;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
|
||||
{
|
||||
internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
||||
{
|
||||
@@ -24,27 +25,28 @@ namespace DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors
|
||||
|
||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||
{
|
||||
var mentionId = Snowflake.TryParse(mention.Id);
|
||||
if (mention.Type == MentionType.Meta)
|
||||
{
|
||||
_buffer.Append($"@{mention.Id}");
|
||||
}
|
||||
else if (mention.Type == MentionType.User)
|
||||
{
|
||||
var member = _context.TryGetMember(Snowflake.Parse(mention.Id));
|
||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
||||
var name = member?.User.Name ?? "Unknown";
|
||||
|
||||
_buffer.Append($"@{name}");
|
||||
}
|
||||
else if (mention.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _context.TryGetChannel(Snowflake.Parse(mention.Id));
|
||||
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer.Append($"#{name}");
|
||||
}
|
||||
else if (mention.Type == MentionType.Role)
|
||||
{
|
||||
var role = _context.TryGetRole(Snowflake.Parse(mention.Id));
|
||||
var role = mentionId?.Pipe(_context.TryGetRole);
|
||||
var name = role?.Name ?? "deleted-role";
|
||||
|
||||
_buffer.Append($"@{name}");
|
||||
+11
-3
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
{
|
||||
internal abstract class MessageWriter : IAsyncDisposable
|
||||
{
|
||||
@@ -11,6 +11,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
protected ExportContext Context { get; }
|
||||
|
||||
public long MessagesWritten { get; private set; }
|
||||
|
||||
public long BytesWritten => Stream.Length;
|
||||
|
||||
protected MessageWriter(Stream stream, ExportContext context)
|
||||
{
|
||||
Stream = stream;
|
||||
@@ -19,7 +23,11 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
public virtual ValueTask WritePreambleAsync() => default;
|
||||
|
||||
public abstract ValueTask WriteMessageAsync(Message message);
|
||||
public virtual ValueTask WriteMessageAsync(Message message)
|
||||
{
|
||||
MessagesWritten++;
|
||||
return default;
|
||||
}
|
||||
|
||||
public virtual ValueTask WritePostambleAsync() => default;
|
||||
|
||||
+14
-13
@@ -2,18 +2,16 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
namespace DiscordChatExporter.Core.Exporting.Writers
|
||||
{
|
||||
internal class PlainTextMessageWriter : MessageWriter
|
||||
{
|
||||
private readonly TextWriter _writer;
|
||||
|
||||
private long _messageCount;
|
||||
|
||||
public PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
: base(stream, context)
|
||||
{
|
||||
@@ -77,10 +75,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.Url));
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.Url));
|
||||
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
await _writer.WriteLineAsync(embed.Footer.Text);
|
||||
@@ -113,15 +111,15 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
await _writer.WriteLineAsync('='.Repeat(62));
|
||||
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
|
||||
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}");
|
||||
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
||||
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
||||
|
||||
if (Context.Request.After != null)
|
||||
if (Context.Request.After is not null)
|
||||
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
|
||||
|
||||
if (Context.Request.Before != null)
|
||||
if (Context.Request.Before is not null)
|
||||
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
|
||||
|
||||
await _writer.WriteLineAsync('='.Repeat(62));
|
||||
@@ -130,26 +128,29 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
public override async ValueTask WriteMessageAsync(Message message)
|
||||
{
|
||||
await base.WriteMessageAsync(message);
|
||||
|
||||
// Header
|
||||
await WriteMessageHeaderAsync(message);
|
||||
|
||||
// Content
|
||||
if (!string.IsNullOrWhiteSpace(message.Content))
|
||||
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
// Attachments, embeds, reactions
|
||||
await WriteAttachmentsAsync(message.Attachments);
|
||||
await WriteEmbedsAsync(message.Embeds);
|
||||
await WriteReactionsAsync(message.Reactions);
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
_messageCount++;
|
||||
}
|
||||
|
||||
public override async ValueTask WritePostambleAsync()
|
||||
{
|
||||
await _writer.WriteLineAsync('='.Repeat(62));
|
||||
await _writer.WriteLineAsync($"Exported {_messageCount:N0} message(s)");
|
||||
await _writer.WriteLineAsync($"Exported {MessagesWritten:N0} message(s)");
|
||||
await _writer.WriteLineAsync('='.Repeat(62));
|
||||
}
|
||||
|
||||
+10
-1
@@ -1,11 +1,20 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class EmojiNode : MarkdownNode
|
||||
{
|
||||
// Only present on custom emoji
|
||||
public string? Id { get; }
|
||||
|
||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||
public string Name { get; }
|
||||
|
||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
||||
? Name
|
||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
|
||||
public bool IsAnimated { get; }
|
||||
|
||||
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);
|
||||
+1
-11
@@ -1,17 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal enum TextFormatting
|
||||
{
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Spoiler,
|
||||
Quote
|
||||
}
|
||||
|
||||
internal class FormattedNode : MarkdownNode
|
||||
{
|
||||
public TextFormatting Formatting { get; }
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class InlineCodeBlockNode : MarkdownNode
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class LinkNode : MarkdownNode
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal abstract class MarkdownNode
|
||||
{
|
||||
+1
-9
@@ -1,13 +1,5 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal enum MentionType
|
||||
{
|
||||
Meta,
|
||||
User,
|
||||
Channel,
|
||||
Role
|
||||
}
|
||||
|
||||
internal class MentionNode : MarkdownNode
|
||||
{
|
||||
public string Id { get; }
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal enum MentionType
|
||||
{
|
||||
Meta,
|
||||
User,
|
||||
Channel,
|
||||
Role
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class MultiLineCodeBlockNode : MarkdownNode
|
||||
{
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal enum TextFormatting
|
||||
{
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Spoiler,
|
||||
Quote
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class TextNode : MarkdownNode
|
||||
{
|
||||
+32
-19
@@ -1,10 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||
using DiscordChatExporter.Domain.Markdown.Matching;
|
||||
using DiscordChatExporter.Core.Markdown.Ast;
|
||||
using DiscordChatExporter.Core.Markdown.Matching;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
|
||||
internal static partial class MarkdownParser
|
||||
@@ -103,7 +104,7 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// There can be either one or two backticks, but equal number on both sides
|
||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(`{1,2})([^`]+)\\1", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
m => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n'))
|
||||
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n'))
|
||||
);
|
||||
|
||||
// Capture language identifier and then any character until the earliest triple backtick
|
||||
@@ -111,7 +112,7 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// Blank lines at the beginning and end of content are trimmed
|
||||
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||
(_, m) => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||
);
|
||||
|
||||
/* Mentions */
|
||||
@@ -131,19 +132,19 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// Capture <@123456> or <@!123456>
|
||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.User)
|
||||
(_, m) => new MentionNode(m.Groups[1].Value, MentionType.User)
|
||||
);
|
||||
|
||||
// Capture <#123456>
|
||||
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<#(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Channel)
|
||||
(_, m) => new MentionNode(m.Groups[1].Value, MentionType.Channel)
|
||||
);
|
||||
|
||||
// Capture <@&123456>
|
||||
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<@&(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Role)
|
||||
(_, m) => new MentionNode(m.Groups[1].Value, MentionType.Role)
|
||||
);
|
||||
|
||||
/* Emojis */
|
||||
@@ -154,15 +155,26 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// ... or digit followed by enclosing mark
|
||||
// (this does not match all emojis in Discord but it's reasonably accurate enough)
|
||||
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})",
|
||||
DefaultRegexOptions),
|
||||
m => new EmojiNode(m.Groups[1].Value)
|
||||
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
|
||||
(_, m) => new EmojiNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture :thinking: (but only for known emoji codes)
|
||||
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex(":([\\w_]+):", DefaultRegexOptions),
|
||||
(_, m) =>
|
||||
{
|
||||
var name = EmojiIndex.TryGetName(m.Groups[1].Value);
|
||||
return name is not null
|
||||
? new EmojiNode(name)
|
||||
: null;
|
||||
}
|
||||
);
|
||||
|
||||
// Capture <:lul:123456> or <a:lul:123456>
|
||||
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
|
||||
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))
|
||||
(_, m) => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))
|
||||
);
|
||||
|
||||
/* Links */
|
||||
@@ -170,19 +182,19 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// Capture [title](link)
|
||||
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value)
|
||||
(_, m) => new LinkNode(m.Groups[2].Value, m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
|
||||
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[1].Value)
|
||||
(_, m) => new LinkNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Same as auto link but also surrounded by angular brackets
|
||||
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[1].Value)
|
||||
(_, m) => new LinkNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
/* Text */
|
||||
@@ -198,21 +210,21 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
// This escapes it from matching for emoji
|
||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value)
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
|
||||
// This escapes it from matching for emoji
|
||||
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value)
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
|
||||
// This escapes it from matching for formatting or other tokens
|
||||
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value)
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
// Combine all matchers into one
|
||||
@@ -255,7 +267,8 @@ namespace DiscordChatExporter.Domain.Markdown
|
||||
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher
|
||||
CustomEmojiNodeMatcher,
|
||||
CodedStandardEmojiNodeMatcher
|
||||
);
|
||||
|
||||
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Domain.Markdown.Ast;
|
||||
using DiscordChatExporter.Core.Markdown.Ast;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
internal abstract class MarkdownVisitor
|
||||
{
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal class AggregateMatcher<T> : IMatcher<T>
|
||||
{
|
||||
@@ -27,11 +27,11 @@ namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
var match = matcher.TryMatch(stringPart);
|
||||
|
||||
// If there's no match - continue
|
||||
if (match == null)
|
||||
if (match is null)
|
||||
continue;
|
||||
|
||||
// If this match is earlier than previous earliest - replace
|
||||
if (earliestMatch == null || match.StringPart.StartIndex < earliestMatch.StringPart.StartIndex)
|
||||
if (earliestMatch is null || match.StringPart.StartIndex < earliestMatch.StringPart.StartIndex)
|
||||
earliestMatch = match;
|
||||
|
||||
// If the earliest match starts at the very beginning - break,
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal interface IMatcher<T>
|
||||
{
|
||||
@@ -21,7 +21,7 @@ namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
var match = matcher.TryMatch(stringPart.Slice(currentIndex, stringPart.EndIndex - currentIndex));
|
||||
|
||||
// If there's no match - break
|
||||
if (match == null)
|
||||
if (match is null)
|
||||
break;
|
||||
|
||||
// If this match doesn't start immediately at current index - transform and yield fallback first
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal class ParsedMatch<T>
|
||||
{
|
||||
+9
-10
@@ -1,24 +1,19 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal class RegexMatcher<T> : IMatcher<T>
|
||||
{
|
||||
private readonly Regex _regex;
|
||||
private readonly Func<StringPart, Match, T> _transform;
|
||||
private readonly Func<StringPart, Match, T?> _transform;
|
||||
|
||||
public RegexMatcher(Regex regex, Func<StringPart, Match, T> transform)
|
||||
public RegexMatcher(Regex regex, Func<StringPart, Match, T?> transform)
|
||||
{
|
||||
_regex = regex;
|
||||
_transform = transform;
|
||||
}
|
||||
|
||||
public RegexMatcher(Regex regex, Func<Match, T> transform)
|
||||
: this(regex, (p, m) => transform(m))
|
||||
{
|
||||
}
|
||||
|
||||
public ParsedMatch<T>? TryMatch(StringPart stringPart)
|
||||
{
|
||||
var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length);
|
||||
@@ -30,11 +25,15 @@ namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
// Which is super weird because regex.Match(string, int) takes the whole input in context.
|
||||
// So in order to properly account for ^/$ regex tokens, we need to make sure that
|
||||
// the expression also matches on the bigger part of the input.
|
||||
if (!_regex.IsMatch(stringPart.Target.Substring(0, stringPart.EndIndex), stringPart.StartIndex))
|
||||
if (!_regex.IsMatch(stringPart.Target[..stringPart.EndIndex], stringPart.StartIndex))
|
||||
return null;
|
||||
|
||||
var stringPartMatch = stringPart.Slice(match.Index, match.Length);
|
||||
return new ParsedMatch<T>(stringPartMatch, _transform(stringPartMatch, match));
|
||||
var value = _transform(stringPartMatch, match);
|
||||
|
||||
return value is not null
|
||||
? new ParsedMatch<T>(stringPartMatch, value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-9
@@ -1,14 +1,14 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal class StringMatcher<T> : IMatcher<T>
|
||||
{
|
||||
private readonly string _needle;
|
||||
private readonly StringComparison _comparison;
|
||||
private readonly Func<StringPart, T> _transform;
|
||||
private readonly Func<StringPart, T?> _transform;
|
||||
|
||||
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T> transform)
|
||||
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T?> transform)
|
||||
{
|
||||
_needle = needle;
|
||||
_comparison = comparison;
|
||||
@@ -23,14 +23,15 @@ namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
public ParsedMatch<T>? TryMatch(StringPart stringPart)
|
||||
{
|
||||
var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
|
||||
if (index < 0)
|
||||
return null;
|
||||
|
||||
if (index >= 0)
|
||||
{
|
||||
var stringPartMatch = stringPart.Slice(index, _needle.Length);
|
||||
return new ParsedMatch<T>(stringPartMatch, _transform(stringPartMatch));
|
||||
}
|
||||
var stringPartMatch = stringPart.Slice(index, _needle.Length);
|
||||
var value = _transform(stringPartMatch);
|
||||
|
||||
return null;
|
||||
return value is not null
|
||||
? new ParsedMatch<T>(stringPartMatch, value)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal readonly struct StringPart
|
||||
{
|
||||
@@ -10,14 +10,13 @@ namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
|
||||
public int Length { get; }
|
||||
|
||||
public int EndIndex { get; }
|
||||
public int EndIndex => StartIndex + Length;
|
||||
|
||||
public StringPart(string target, int startIndex, int length)
|
||||
{
|
||||
Target = target;
|
||||
StartIndex = startIndex;
|
||||
Length = length;
|
||||
EndIndex = startIndex + length;
|
||||
}
|
||||
|
||||
public StringPart(string target)
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -5,7 +5,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Utilities
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
public static class AsyncExtensions
|
||||
{
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
internal static class BinaryExtensions
|
||||
public static class BinaryExtensions
|
||||
{
|
||||
public static string ToHex(this byte[] data)
|
||||
{
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
using System.Drawing;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
internal static class ColorExtensions
|
||||
public static class ColorExtensions
|
||||
{
|
||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user