mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 06:39:33 +02:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,42 @@
|
||||
name: "\U0001F41E Bug report"
|
||||
description: Report broken functionality.
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
- 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, 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: 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,26 @@
|
||||
name: "\U00002728 Feature request"
|
||||
description: Request a new feature.
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
- 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, 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?"
|
||||
multiple: true
|
||||
options:
|
||||
- GUI (Graphical User Interface)
|
||||
- CLI (Command Line Interface)
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: "Clear and thorough explanation of the feature you have in mind."
|
||||
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,19 @@
|
||||
### 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.27</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
@@ -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.39.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,48 @@
|
||||
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(description, new ProgressTaskSettings {MaxValue = 1});
|
||||
|
||||
try
|
||||
{
|
||||
await performOperationAsync(progressTask);
|
||||
}
|
||||
finally
|
||||
{
|
||||
progressTask.StopTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public enum AuthTokenType { User, Bot }
|
||||
|
||||
@@ -16,7 +16,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)
|
||||
+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;
|
||||
+36
-18
@@ -1,11 +1,11 @@
|
||||
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;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
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.
|
||||
@@ -21,7 +21,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
}
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#channel-object
|
||||
public partial class Channel : IHasId
|
||||
public partial class Channel : IHasId, IHasPosition
|
||||
{
|
||||
public Snowflake Id { get; }
|
||||
|
||||
@@ -36,38 +36,53 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
|
||||
public Snowflake GuildId { get; }
|
||||
|
||||
public string Category { 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, string category, string name, string? topic)
|
||||
public Channel(
|
||||
Snowflake id,
|
||||
ChannelType type,
|
||||
Snowflake guildId,
|
||||
ChannelCategory? category,
|
||||
string name,
|
||||
int? position,
|
||||
string? topic)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
GuildId = guildId;
|
||||
Category = category;
|
||||
Category = category ?? GetFallbackCategory(type);
|
||||
Name = name;
|
||||
Position = position;
|
||||
Topic = topic;
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
|
||||
}
|
||||
|
||||
public partial class Channel
|
||||
{
|
||||
private static string GetDefaultCategory(ChannelType channelType) => channelType switch
|
||||
{
|
||||
ChannelType.GuildTextChat => "Text",
|
||||
ChannelType.DirectTextChat => "Private",
|
||||
ChannelType.DirectGroupTextChat => "Group",
|
||||
ChannelType.GuildNews => "News",
|
||||
ChannelType.GuildStore => "Store",
|
||||
_ => "Default"
|
||||
};
|
||||
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"
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
public static Channel Parse(JsonElement json, string? category = 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);
|
||||
@@ -80,12 +95,15 @@ namespace DiscordChatExporter.Domain.Discord.Models
|
||||
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
|
||||
id.ToString();
|
||||
|
||||
position ??= json.GetPropertyOrNull("position")?.GetInt32();
|
||||
|
||||
return new Channel(
|
||||
id,
|
||||
type,
|
||||
guildId ?? Guild.DirectMessages.Id,
|
||||
category ?? GetDefaultCategory(type),
|
||||
category ?? GetFallbackCategory(type),
|
||||
name,
|
||||
position,
|
||||
topic
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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
|
||||
{
|
||||
public partial class ChannelCategory : IHasId, IHasPosition
|
||||
{
|
||||
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);
|
||||
position ??= json.GetPropertyOrNull("position")?.GetInt32();
|
||||
|
||||
var name = json.GetPropertyOrNull("name")?.GetString() ??
|
||||
json.GetPropertyOrNull("recipients")?.EnumerateArray().Select(User.Parse).Select(u => u.Name).JoinToString(", ") ??
|
||||
id.ToString();
|
||||
|
||||
return new ChannelCategory(
|
||||
id,
|
||||
name,
|
||||
position
|
||||
);
|
||||
}
|
||||
|
||||
public static ChannelCategory Empty { get; } = new(Snowflake.Zero, "<unknown category>", 0);
|
||||
}
|
||||
}
|
||||
+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
|
||||
{
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common
|
||||
{
|
||||
public interface IHasPosition
|
||||
{
|
||||
int? Position { get; }
|
||||
}
|
||||
}
|
||||
+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>
|
||||
{
|
||||
+2
-3
@@ -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
|
||||
+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-author-structure
|
||||
public partial class EmbedAuthor
|
||||
+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
|
||||
+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-footer-structure
|
||||
public partial class EmbedFooter
|
||||
+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-image-structure
|
||||
public partial class EmbedImage
|
||||
+1
-1
@@ -5,7 +5,7 @@ using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord.Models
|
||||
namespace DiscordChatExporter.Core.Discord.Data
|
||||
{
|
||||
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
||||
public partial class Emoji
|
||||
+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";
|
||||
+8
-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/guild#guild-member-object
|
||||
public partial class Member : IHasId
|
||||
@@ -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,
|
||||
null,
|
||||
Array.Empty<Snowflake>()
|
||||
);
|
||||
|
||||
public static Member Parse(JsonElement json)
|
||||
{
|
||||
+3
-3
@@ -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
|
||||
+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
|
||||
+2
-2
@@ -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
|
||||
+8
-5
@@ -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;
|
||||
+12
-5
@@ -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;
|
||||
+56
-23
@@ -5,14 +5,15 @@ 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;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public class DiscordClient
|
||||
{
|
||||
@@ -34,7 +35,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
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);
|
||||
});
|
||||
@@ -117,26 +118,34 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels");
|
||||
|
||||
var categories = response
|
||||
var orderedResponse = response
|
||||
.EnumerateArray()
|
||||
.ToDictionary(
|
||||
j => j.GetProperty("id").GetString(),
|
||||
j => j.GetProperty("name").GetString()
|
||||
);
|
||||
.OrderBy(j => j.GetProperty("position").GetInt32())
|
||||
.ThenBy(j => ulong.Parse(j.GetProperty("id").GetString()))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channelJson in response.EnumerateArray())
|
||||
var categories = orderedResponse
|
||||
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelType.GuildCategory)
|
||||
.Select((j, index) => ChannelCategory.Parse(j, index + 1))
|
||||
.ToDictionary(j => j.Id.ToString());
|
||||
|
||||
var position = 0;
|
||||
|
||||
foreach (var channelJson in orderedResponse)
|
||||
{
|
||||
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
|
||||
if (!channel.IsTextChannel)
|
||||
continue;
|
||||
|
||||
position++;
|
||||
|
||||
yield return channel;
|
||||
}
|
||||
}
|
||||
@@ -162,10 +171,22 @@ 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 some channel category info.
|
||||
* Instead, we use an empty channel category as a fallback.
|
||||
*/
|
||||
catch (DiscordChatExporterException)
|
||||
{
|
||||
return ChannelCategory.Empty;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async ValueTask<Channel> GetChannelAsync(Snowflake channelId)
|
||||
@@ -174,7 +195,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;
|
||||
|
||||
@@ -204,7 +225,7 @@ namespace DiscordChatExporter.Domain.Discord
|
||||
// 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 +260,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 can happen easily if there's only one message in the channel)
|
||||
else
|
||||
{
|
||||
progress.Report(1);
|
||||
}
|
||||
}
|
||||
|
||||
yield return message;
|
||||
currentAfter = message.Id;
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Discord
|
||||
namespace DiscordChatExporter.Core.Discord
|
||||
{
|
||||
public readonly partial struct Snowflake
|
||||
{
|
||||
@@ -32,7 +32,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,7 +48,7 @@ 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);
|
||||
}
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="JsonExtensions" Version="1.0.1" />
|
||||
<PackageReference Include="MiniRazor" Version="2.0.3" />
|
||||
<PackageReference Include="Polly" Version="7.2.1" />
|
||||
<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
|
||||
{
|
||||
+5
-5
@@ -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();
|
||||
+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}");
|
||||
}
|
||||
+4
-5
@@ -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
|
||||
{
|
||||
@@ -63,7 +62,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
: (DateTimeOffset?) null
|
||||
);
|
||||
|
||||
if (lastModified != null)
|
||||
if (lastModified is not null)
|
||||
{
|
||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
+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(',');
|
||||
+2
-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
|
||||
+19
-19
@@ -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,12 +45,12 @@
|
||||
}
|
||||
|
||||
<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>
|
||||
@@ -66,7 +66,7 @@
|
||||
}
|
||||
</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>
|
||||
}
|
||||
@@ -98,13 +98,13 @@
|
||||
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)
|
||||
@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>
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
<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,7 +175,7 @@
|
||||
<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))
|
||||
@@ -249,7 +249,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (embed.Thumbnail != null)
|
||||
@if (embed.Thumbnail is not null)
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.Url)">
|
||||
@@ -259,7 +259,7 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (embed.Image != null)
|
||||
@if (embed.Image is not null)
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.Url)">
|
||||
@@ -268,7 +268,7 @@
|
||||
</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))
|
||||
@@ -282,12 +282,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)
|
||||
}
|
||||
+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)
|
||||
+23
-21
@@ -1,27 +1,26 @@
|
||||
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,
|
||||
SkipValidation = true
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,19 +116,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 +167,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 +191,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 +210,8 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
public override async ValueTask WriteMessageAsync(Message message)
|
||||
{
|
||||
await base.WriteMessageAsync(message);
|
||||
|
||||
_writer.WriteStartObject();
|
||||
|
||||
// Metadata
|
||||
@@ -227,7 +229,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 +269,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 +280,6 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync();
|
||||
|
||||
_messageCount++;
|
||||
}
|
||||
|
||||
public override async ValueTask WritePostambleAsync()
|
||||
@@ -285,7 +287,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
// Message array (end)
|
||||
_writer.WriteEndArray();
|
||||
|
||||
_writer.WriteNumber("messageCount", _messageCount);
|
||||
_writer.WriteNumber("messageCount", MessagesWritten);
|
||||
|
||||
// Root object (end)
|
||||
_writer.WriteEndObject();
|
||||
@@ -298,4 +300,4 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -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);"
|
||||
: "";
|
||||
|
||||
+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;
|
||||
|
||||
+12
-11
@@ -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)
|
||||
{
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class EmojiNode : MarkdownNode
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal enum TextFormatting
|
||||
{
|
||||
+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
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal enum MentionType
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class MultiLineCodeBlockNode : MarkdownNode
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Domain.Markdown.Ast
|
||||
namespace DiscordChatExporter.Core.Markdown.Ast
|
||||
{
|
||||
internal class TextNode : MarkdownNode
|
||||
{
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
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;
|
||||
|
||||
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
|
||||
+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>
|
||||
{
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal class RegexMatcher<T> : IMatcher<T>
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal class StringMatcher<T> : IMatcher<T>
|
||||
{
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Markdown.Matching
|
||||
namespace DiscordChatExporter.Core.Markdown.Matching
|
||||
{
|
||||
internal readonly struct StringPart
|
||||
{
|
||||
+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);
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
internal static class DateExtensions
|
||||
public static class DateExtensions
|
||||
{
|
||||
public static string ToLocalString(this DateTimeOffset dateTime, string format) =>
|
||||
dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
|
||||
+4
-2
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
internal static class GenericExtensions
|
||||
public static class GenericExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input);
|
||||
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct =>
|
||||
!predicate(value)
|
||||
? value
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
internal static class HttpExtensions
|
||||
public static class HttpExtensions
|
||||
{
|
||||
public static string? TryGetValue(this HttpContentHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values)
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions
|
||||
{
|
||||
internal static class StringExtensions
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static string Truncate(this string str, int charCount) =>
|
||||
str.Length > charCount
|
||||
+3
-3
@@ -7,9 +7,9 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Polly;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal
|
||||
namespace DiscordChatExporter.Core.Utils
|
||||
{
|
||||
internal static class Http
|
||||
public static class Http
|
||||
{
|
||||
public static HttpClient Client { get; } = new();
|
||||
|
||||
@@ -32,7 +32,7 @@ namespace DiscordChatExporter.Domain.Internal
|
||||
if (i > 3)
|
||||
{
|
||||
var retryAfterDelay = result.Result.Headers.RetryAfter.Delta;
|
||||
if (retryAfterDelay != null)
|
||||
if (retryAfterDelay is not null)
|
||||
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user