mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 22:54:38 +02:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a0d351143 | |||
| f04719c4bd | |||
| d88cd9b228 | |||
| cd042e5368 | |||
| 533671c59f | |||
| 29c35f6754 | |||
| 7b32101517 | |||
| 9bb88128b0 | |||
| 12a5091d73 | |||
| 82af36c90d | |||
| a2beb9836b | |||
| 60f46bad29 | |||
| 2cdb230b1e | |||
| 9241b7d2d1 | |||
| 40f54d9a98 | |||
| f2faf823b9 | |||
| 5b9eaa57f5 | |||
| 58ba99e0c6 | |||
| 117d1b7e4a | |||
| 835910ee22 | |||
| 5630020d88 | |||
| 71e6a8de7a | |||
| 592b8b6294 | |||
| e6edcd27a4 | |||
| 557b5be844 | |||
| 02fe2a46e8 | |||
| 76c7f9b419 | |||
| 9a383d2bd4 | |||
| 2ed374ee5c | |||
| 0ced31ca4a | |||
| 3bb5638b56 | |||
| 3b5d752c95 | |||
| 2b1f599cbe | |||
| 7cb1c21897 | |||
| 37bcea86e3 | |||
| 559ef71d2f | |||
| ab0ce478db | |||
| afb9f52221 | |||
| d65ca409b1 | |||
| 7784383ea7 | |||
| 26322da800 | |||
| 1325edb135 | |||
| dec2c26d83 | |||
| 82b0b8cb0a | |||
| a7da90943f | |||
| 1edeaf9d3a | |||
| 9c043376f9 | |||
| 77366cc9b4 | |||
| ffdca7b799 | |||
| 30cba7959f | |||
| 7951703cf2 | |||
| 795dfbb213 | |||
| 6d9bc3625f | |||
| 4bfb2ec7fd | |||
| 359278afec | |||
| 19f58f2c8c | |||
| 640b1f5866 | |||
| d13d04e75b | |||
| 6bf0ce3790 | |||
| aef8a1f6d2 | |||
| a63bf95212 | |||
| af9855fc15 | |||
| 4113101c0c | |||
| cac48b3541 | |||
| 0a60dea5c1 | |||
| 889446fff7 | |||
| 6d1cfef729 |
@@ -0,0 +1,4 @@
|
||||
github: Tyrrrz
|
||||
patreon: Tyrrrz
|
||||
open_collective: Tyrrrz
|
||||
custom: ['buymeacoffee.com/Tyrrrz']
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -1,3 +1,48 @@
|
||||
### v2.15 (15-Sep-2019)
|
||||
|
||||
- Improved markdown parser and made it even faster for non-HTML formats.
|
||||
- [HTML] Added support for block quotes.
|
||||
- [HTML] Links pointing to a Discord message will now navigate to the linked message inside exported chat log if it's there.
|
||||
- [HTML] Updated light theme to match how it looks in Discord after recent changes.
|
||||
- [HTML] Added indication for when a message is pinned. Pinned messages now have a tinted background.
|
||||
- [HTML] Fixed an issue where multiline code blocks sometimes had incorrect formatting applied to them.
|
||||
- [TXT] Added indication for when a message is pinned. Pinned messages now have `(pinned)` next to timestamp and author.
|
||||
- [CSV] Added message author's user ID to output.
|
||||
- [GUI] Streamlined auto-update process a bit.
|
||||
- [GUI] Added some tooltips.
|
||||
|
||||
### v2.14 (15-Jun-2019)
|
||||
|
||||
- [TXT] Added support for embeds.
|
||||
- [TXT] Added support for reactions.
|
||||
- [CSV] Added support for reactions.
|
||||
- [TXT] Changed how attachments are rendered.
|
||||
|
||||
### v2.13.1 (06-Jun-2019)
|
||||
|
||||
- Fixed an issue where the app sometimes crashed when exporting due to `System.InvalidCastException`.
|
||||
- [HTML] Added `data-user-id` attribute to `span.chatlog__author-name`. The value of this attribute is author's Discord user ID.
|
||||
|
||||
### v2.13 (15-May-2019)
|
||||
|
||||
- Updated usage instructions.
|
||||
- [HTML] Added BOT tags to messages posted by bots.
|
||||
- [GUI] UI will now be disabled when exporting.
|
||||
- [GUI] Notification informing that the export has completed will now appear after all exports have completed, instead of each of them separately.
|
||||
|
||||
### v2.12.1 (12-Apr-2019)
|
||||
|
||||
- [GUI] Fixed an issue where the app crashed when trying to export multiple channels.
|
||||
- [HTML] Fixed an issue where some of the emojis were rendered via Twemoji while Discord renders them as plain text.
|
||||
- [HTML] Fixed an issue where emojis weren't jumbo if there were more than one of them.
|
||||
|
||||
### v2.12 (11-Apr-2019)
|
||||
|
||||
- Improved markdown parsing performance which speeds up the final stage of exporting by around 4.5 times.
|
||||
- Improved performance when exporting as PlainText or CSV.
|
||||
- [GUI] Fixed an issue where the app would freeze up and report as "not responding" during the final stage of exporting.
|
||||
- [CLI] Fixed an issue where it would say "Completed" after exporting a chat log, even if the process failed.
|
||||
|
||||
### v2.11 (14-Mar-2019)
|
||||
|
||||
- [HTML] Added syntax highlighting for multiline code blocks via Highlight.js.
|
||||
|
||||
+14
-6
@@ -1,13 +1,21 @@
|
||||
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
|
||||
New-Item "$PSScriptRoot\Portable\bin" -ItemType Directory -Force
|
||||
|
||||
# GUI
|
||||
# --- GUI ---
|
||||
|
||||
# Get files
|
||||
$files = @()
|
||||
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.zip" -Force
|
||||
|
||||
# CLI
|
||||
# Pack into archive
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.zip" -Force
|
||||
|
||||
# --- CLI ---
|
||||
|
||||
# Get files
|
||||
$files = @()
|
||||
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\bin\DiscordChatExporter.CLI.zip" -Force
|
||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net46\*" -Include "*.exe", "*.dll", "*.config"
|
||||
|
||||
# Pack into archive
|
||||
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\bin\DiscordChatExporter.CLI.zip" -Force
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("export", Description = "Export a channel.")]
|
||||
public class ExportChannelCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("channel", 'c', IsRequired = true, Description= "Channel ID.")]
|
||||
public string ChannelId { get; set; }
|
||||
|
||||
public ExportChannelCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
|
||||
: base(settingsService, dataService, exportService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Get channel
|
||||
var channel = await DataService.GetChannelAsync(GetToken(), ChannelId);
|
||||
|
||||
// Export
|
||||
await ExportChannelAsync(console, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Cli.Internal;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Core.Services.Helpers;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public abstract class ExportCommandBase : TokenCommandBase
|
||||
{
|
||||
protected SettingsService SettingsService { get; }
|
||||
|
||||
protected ExportService ExportService { get; }
|
||||
|
||||
|
||||
[CommandOption("format", 'f', Description = "Output file format.")]
|
||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
[CommandOption("output", 'o', Description = "Output file or directory path.")]
|
||||
public string OutputPath { get; set; }
|
||||
|
||||
[CommandOption("after",Description = "Limit to messages sent after this date.")]
|
||||
public DateTimeOffset? After { get; set; }
|
||||
|
||||
[CommandOption("before",Description = "Limit to messages sent before this date.")]
|
||||
public DateTimeOffset? Before { get; set; }
|
||||
|
||||
[CommandOption("partition", 'p', Description = "Split output into partitions limited to this number of messages.")]
|
||||
public int? PartitionLimit { get; set; }
|
||||
|
||||
[CommandOption("dateformat", Description = "Date format used in output.")]
|
||||
public string DateFormat { get; set; }
|
||||
|
||||
protected ExportCommandBase(SettingsService settingsService, DataService dataService, ExportService exportService)
|
||||
: base(dataService)
|
||||
{
|
||||
SettingsService = settingsService;
|
||||
ExportService = exportService;
|
||||
}
|
||||
|
||||
protected async Task ExportChannelAsync(IConsole console, Channel channel)
|
||||
{
|
||||
// Configure settings
|
||||
if (!DateFormat.IsNullOrWhiteSpace())
|
||||
SettingsService.DateFormat = DateFormat;
|
||||
|
||||
console.Output.Write($"Exporting channel [{channel.Name}]... ");
|
||||
using (var progress = new InlineProgress(console))
|
||||
{
|
||||
// Get chat log
|
||||
var chatLog = await DataService.GetChatLogAsync(GetToken(), channel, After, Before, progress);
|
||||
|
||||
// Generate file path if not set or is a directory
|
||||
var filePath = OutputPath;
|
||||
if (filePath.IsNullOrWhiteSpace() || ExportHelper.IsDirectoryPath(filePath))
|
||||
{
|
||||
// Generate default file name
|
||||
var fileName = ExportHelper.GetDefaultExportFileName(ExportFormat, chatLog.Guild,
|
||||
chatLog.Channel, After, Before);
|
||||
|
||||
// Combine paths
|
||||
filePath = Path.Combine(filePath ?? "", fileName);
|
||||
}
|
||||
|
||||
// Export
|
||||
await ExportService.ExportChatLogAsync(chatLog, filePath, ExportFormat, PartitionLimit);
|
||||
|
||||
// Report successful completion
|
||||
progress.ReportCompletion();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Core.Services.Exceptions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportdm", Description = "Export all direct message channels.")]
|
||||
public class ExportDirectMessagesCommand : ExportCommandBase
|
||||
{
|
||||
public ExportDirectMessagesCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
|
||||
: base(settingsService, dataService, exportService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Get channels
|
||||
var channels = await DataService.GetDirectMessageChannelsAsync(GetToken());
|
||||
|
||||
// Order channels
|
||||
channels = channels.OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Loop through channels
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExportChannelAsync(console, channel);
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
console.Error.WriteLine("You don't have access to this channel.");
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
console.Error.WriteLine("This channel doesn't exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using DiscordChatExporter.Core.Services.Exceptions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("exportguild", Description = "Export all channels within specified guild.")]
|
||||
public class ExportGuildCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||
public string GuildId { get; set; }
|
||||
|
||||
public ExportGuildCommand(SettingsService settingsService, DataService dataService, ExportService exportService)
|
||||
: base(settingsService, dataService, exportService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Get channels
|
||||
var channels = await DataService.GetGuildChannelsAsync(GetToken(), GuildId);
|
||||
|
||||
// Filter and order channels
|
||||
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Loop through channels
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ExportChannelAsync(console, channel);
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
console.Error.WriteLine("You don't have access to this channel.");
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
console.Error.WriteLine("This channel doesn't exist.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("channels", Description = "Get the list of channels in specified guild.")]
|
||||
public class GetChannelsCommand : TokenCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
|
||||
public string GuildId { get; set; }
|
||||
|
||||
public GetChannelsCommand(DataService dataService)
|
||||
: base(dataService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Get channels
|
||||
var channels = await DataService.GetGuildChannelsAsync(GetToken(), GuildId);
|
||||
|
||||
// Filter and order channels
|
||||
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels)
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("dm", Description = "Get the list of direct message channels.")]
|
||||
public class GetDirectMessageChannelsCommand : TokenCommandBase
|
||||
{
|
||||
public GetDirectMessageChannelsCommand(DataService dataService)
|
||||
: base(dataService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Get channels
|
||||
var channels = await DataService.GetDirectMessageChannelsAsync(GetToken());
|
||||
|
||||
// Order channels
|
||||
channels = channels.OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels)
|
||||
console.Output.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("guilds", Description = "Get the list of accessible guilds.")]
|
||||
public class GetGuildsCommand : TokenCommandBase
|
||||
{
|
||||
public GetGuildsCommand(DataService dataService)
|
||||
: base(dataService)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Get guilds
|
||||
var guilds = await DataService.GetUserGuildsAsync(GetToken());
|
||||
|
||||
// Order guilds
|
||||
guilds = guilds.OrderBy(g => g.Name).ToArray();
|
||||
|
||||
// Print result
|
||||
foreach (var guild in guilds)
|
||||
console.Output.WriteLine($"{guild.Id} | {guild.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
[Command("guide", Description = "Explains how to obtain token, guild or channel ID.")]
|
||||
public class GuideCommand : ICommand
|
||||
{
|
||||
public Task ExecuteAsync(IConsole console)
|
||||
{
|
||||
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");
|
||||
console.Output.WriteLine(" 4. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
||||
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
|
||||
console.Output.WriteLine(" 6. Find \"token\" at the bottom and copy the value");
|
||||
console.Output.WriteLine();
|
||||
|
||||
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:"));
|
||||
console.Output.WriteLine(" 1. Open Discord");
|
||||
console.Output.WriteLine(" 2. Open Settings");
|
||||
console.Output.WriteLine(" 3. Go to Appearance section");
|
||||
console.Output.WriteLine(" 4. Enable Developer Mode");
|
||||
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:"));
|
||||
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");
|
||||
console.Output.WriteLine(" 4. Navigate to the Console tab");
|
||||
console.Output.WriteLine(" 5. Type \"window.location.href\" and press Enter");
|
||||
console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL");
|
||||
console.Output.WriteLine();
|
||||
|
||||
console.Output.WriteLine("If you still have unanswered questions, check out the wiki:");
|
||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Services;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands
|
||||
{
|
||||
public abstract class TokenCommandBase : ICommand
|
||||
{
|
||||
protected DataService DataService { get; }
|
||||
|
||||
[CommandOption("token", 't', IsRequired = true, Description = "Authorization token.")]
|
||||
public string TokenValue { get; set; }
|
||||
|
||||
[CommandOption("bot", 'b', Description = "Whether this authorization token belongs to a bot.")]
|
||||
public bool IsBotToken { get; set; }
|
||||
|
||||
protected TokenCommandBase(DataService dataService)
|
||||
{
|
||||
DataService = dataService;
|
||||
}
|
||||
|
||||
protected AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
||||
|
||||
public abstract Task ExecuteAsync(IConsole console);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using StyletIoC;
|
||||
|
||||
namespace DiscordChatExporter.Cli
|
||||
{
|
||||
public static class Container
|
||||
{
|
||||
public static IContainer Instance { get; }
|
||||
|
||||
static Container()
|
||||
{
|
||||
var builder = new StyletIoCBuilder();
|
||||
|
||||
// Autobind services in the .Core assembly
|
||||
builder.Autobind(typeof(DataService).Assembly);
|
||||
|
||||
// Bind settings as singleton
|
||||
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
|
||||
|
||||
// Set instance
|
||||
Instance = builder.BuildContainer();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<Version>2.11</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
|
||||
<Version>2.15</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommandLineParser" Version="2.3.0" />
|
||||
<PackageReference Include="Stylet" Version="1.1.22" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="0.0.5" />
|
||||
<PackageReference Include="Stylet" Version="1.2.0" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,40 +1,48 @@
|
||||
using System;
|
||||
using CliFx.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Internal
|
||||
{
|
||||
internal class InlineProgress : IProgress<double>, IDisposable
|
||||
{
|
||||
private readonly int _posX;
|
||||
private readonly int _posY;
|
||||
private readonly IConsole _console;
|
||||
|
||||
public InlineProgress()
|
||||
private string _lastOutput = "";
|
||||
private bool _isCompleted;
|
||||
|
||||
public InlineProgress(IConsole console)
|
||||
{
|
||||
// If output is not redirected - save initial cursor position
|
||||
if (!Console.IsOutputRedirected)
|
||||
{
|
||||
_posX = Console.CursorLeft;
|
||||
_posY = Console.CursorTop;
|
||||
}
|
||||
_console = console;
|
||||
}
|
||||
|
||||
private void ResetCursorPosition()
|
||||
{
|
||||
foreach (var c in _lastOutput)
|
||||
_console.Output.Write('\b');
|
||||
}
|
||||
|
||||
public void Report(double progress)
|
||||
{
|
||||
// If output is not redirected - reset cursor position and write progress
|
||||
if (!Console.IsOutputRedirected)
|
||||
if (!_console.IsOutputRedirected)
|
||||
{
|
||||
Console.SetCursorPosition(_posX, _posY);
|
||||
Console.WriteLine($"{progress:P1}");
|
||||
ResetCursorPosition();
|
||||
_console.Output.Write(_lastOutput = $"{progress:P1}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ReportCompletion() => _isCompleted = true;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// If output is not redirected - reset cursor position
|
||||
if (!Console.IsOutputRedirected)
|
||||
Console.SetCursorPosition(_posX, _posY);
|
||||
if (!_console.IsOutputRedirected)
|
||||
{
|
||||
ResetCursorPosition();
|
||||
}
|
||||
|
||||
// Inform about completion
|
||||
Console.WriteLine("Completed ✓");
|
||||
_console.Output.WriteLine(_isCompleted ? "Completed ✓" : "Failed X");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,35 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Cli.Verbs;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using StyletIoC;
|
||||
|
||||
namespace DiscordChatExporter.Cli
|
||||
{
|
||||
public static class Program
|
||||
{
|
||||
private static void PrintTokenHelp()
|
||||
private static IContainer BuildContainer()
|
||||
{
|
||||
Console.WriteLine("# To get user token:");
|
||||
Console.WriteLine(" 1. Open Discord");
|
||||
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||
Console.WriteLine(" 3. Press Ctrl+R to reload");
|
||||
Console.WriteLine(" 4. Navigate to the Application tab");
|
||||
Console.WriteLine(" 5. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
||||
Console.WriteLine(" 6. Find \"token\" under key and copy the value");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("# To get bot token:");
|
||||
Console.WriteLine(" 1. Go to Discord developer portal");
|
||||
Console.WriteLine(" 2. Open your application's settings");
|
||||
Console.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||
Console.WriteLine(" 4. Under Token click Copy");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("# To get guild or channel ID:");
|
||||
Console.WriteLine(" 1. Open Discord");
|
||||
Console.WriteLine(" 2. Open Settings");
|
||||
Console.WriteLine(" 3. Go to Appearance section");
|
||||
Console.WriteLine(" 4. Enable Developer Mode");
|
||||
Console.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||
var builder = new StyletIoCBuilder();
|
||||
|
||||
// Autobind the .Services assembly
|
||||
builder.Autobind(typeof(DataService).Assembly);
|
||||
|
||||
// Bind settings as singleton
|
||||
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
|
||||
|
||||
// Set instance
|
||||
return builder.BuildContainer();
|
||||
}
|
||||
|
||||
public static void Main(string[] args)
|
||||
public static Task<int> Main(string[] args)
|
||||
{
|
||||
// Get all verb types
|
||||
var verbTypes = new[]
|
||||
{
|
||||
typeof(ExportChannelOptions),
|
||||
typeof(ExportDirectMessagesOptions),
|
||||
typeof(ExportGuildOptions),
|
||||
typeof(GetChannelsOptions),
|
||||
typeof(GetDirectMessageChannelsOptions),
|
||||
typeof(GetGuildsOptions)
|
||||
};
|
||||
var container = BuildContainer();
|
||||
|
||||
// Parse command line arguments
|
||||
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
|
||||
|
||||
// Execute commands
|
||||
parsedArgs.WithParsed<ExportChannelOptions>(o => new ExportChannelVerb(o).Execute());
|
||||
parsedArgs.WithParsed<ExportDirectMessagesOptions>(o => new ExportDirectMessagesVerb(o).Execute());
|
||||
parsedArgs.WithParsed<ExportGuildOptions>(o => new ExportGuildVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
|
||||
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
|
||||
|
||||
// Show token help if help requested or no verb specified
|
||||
parsedArgs.WithNotParsed(errs =>
|
||||
{
|
||||
var err = errs.First();
|
||||
|
||||
if (err.Tag == ErrorType.NoVerbSelectedError)
|
||||
PrintTokenHelp();
|
||||
|
||||
if (err.Tag == ErrorType.HelpVerbRequestedError && args.Length == 1)
|
||||
PrintTokenHelp();
|
||||
});
|
||||
return new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.UseCommandFactory(schema => (ICommand) container.Get(schema.Type))
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Internal;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Helpers;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class ExportChannelVerb : Verb<ExportChannelOptions>
|
||||
{
|
||||
public ExportChannelVerb(ExportChannelOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get services
|
||||
var settingsService = Container.Instance.Get<SettingsService>();
|
||||
var dataService = Container.Instance.Get<DataService>();
|
||||
var exportService = Container.Instance.Get<ExportService>();
|
||||
|
||||
// Configure settings
|
||||
if (Options.DateFormat.IsNotBlank())
|
||||
settingsService.DateFormat = Options.DateFormat;
|
||||
|
||||
// Track progress
|
||||
Console.Write($"Exporting channel [{Options.ChannelId}]... ");
|
||||
using (var progress = new InlineProgress())
|
||||
{
|
||||
// Get chat log
|
||||
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
|
||||
Options.After, Options.Before, progress);
|
||||
|
||||
// Generate file path if not set or is a directory
|
||||
var filePath = Options.OutputPath;
|
||||
if (filePath.IsBlank() || ExportHelper.IsDirectoryPath(filePath))
|
||||
{
|
||||
// Generate default file name
|
||||
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
|
||||
chatLog.Channel, Options.After, Options.Before);
|
||||
|
||||
// Combine paths
|
||||
filePath = Path.Combine(filePath ?? "", fileName);
|
||||
}
|
||||
|
||||
// Export
|
||||
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Internal;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Helpers;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class ExportDirectMessagesVerb : Verb<ExportDirectMessagesOptions>
|
||||
{
|
||||
public ExportDirectMessagesVerb(ExportDirectMessagesOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get services
|
||||
var settingsService = Container.Instance.Get<SettingsService>();
|
||||
var dataService = Container.Instance.Get<DataService>();
|
||||
var exportService = Container.Instance.Get<ExportService>();
|
||||
|
||||
// Configure settings
|
||||
if (Options.DateFormat.IsNotBlank())
|
||||
settingsService.DateFormat = Options.DateFormat;
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
|
||||
|
||||
// Order channels
|
||||
channels = channels.OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Loop through channels
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Track progress
|
||||
Console.Write($"Exporting channel [{channel.Name}]... ");
|
||||
using (var progress = new InlineProgress())
|
||||
{
|
||||
// Get chat log
|
||||
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
|
||||
Options.After, Options.Before, progress);
|
||||
|
||||
// Generate default file name
|
||||
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
|
||||
chatLog.Channel, Options.After, Options.Before);
|
||||
|
||||
// Generate file path
|
||||
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
|
||||
|
||||
// Export
|
||||
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
|
||||
}
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
Console.Error.WriteLine("You don't have access to this channel");
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
Console.Error.WriteLine("This channel doesn't exist");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Internal;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Helpers;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class ExportGuildVerb : Verb<ExportGuildOptions>
|
||||
{
|
||||
public ExportGuildVerb(ExportGuildOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get services
|
||||
var settingsService = Container.Instance.Get<SettingsService>();
|
||||
var dataService = Container.Instance.Get<DataService>();
|
||||
var exportService = Container.Instance.Get<ExportService>();
|
||||
|
||||
// Configure settings
|
||||
if (Options.DateFormat.IsNotBlank())
|
||||
settingsService.DateFormat = Options.DateFormat;
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
|
||||
|
||||
// Filter and order channels
|
||||
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Loop through channels
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Track progress
|
||||
Console.Write($"Exporting channel [{channel.Name}]... ");
|
||||
using (var progress = new InlineProgress())
|
||||
{
|
||||
// Get chat log
|
||||
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), channel,
|
||||
Options.After, Options.Before, progress);
|
||||
|
||||
// Generate default file name
|
||||
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
|
||||
chatLog.Channel, Options.After, Options.Before);
|
||||
|
||||
// Generate file path
|
||||
var filePath = Path.Combine(Options.OutputPath ?? "", fileName);
|
||||
|
||||
// Export
|
||||
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
|
||||
}
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
|
||||
{
|
||||
Console.Error.WriteLine("You don't have access to this channel");
|
||||
}
|
||||
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
Console.Error.WriteLine("This channel doesn't exist");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetChannelsVerb : Verb<GetChannelsOptions>
|
||||
{
|
||||
public GetChannelsVerb(GetChannelsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var dataService = Container.Instance.Get<DataService>();
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
|
||||
|
||||
// Filter and order channels
|
||||
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat).OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels)
|
||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetDirectMessageChannelsVerb : Verb<GetDirectMessageChannelsOptions>
|
||||
{
|
||||
public GetDirectMessageChannelsVerb(GetDirectMessageChannelsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var dataService = Container.Instance.Get<DataService>();
|
||||
|
||||
// Get channels
|
||||
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
|
||||
|
||||
// Order channels
|
||||
channels = channels.OrderBy(c => c.Name).ToArray();
|
||||
|
||||
// Print result
|
||||
foreach (var channel in channels)
|
||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Verbs.Options;
|
||||
using DiscordChatExporter.Core.Services;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public class GetGuildsVerb : Verb<GetGuildsOptions>
|
||||
{
|
||||
public GetGuildsVerb(GetGuildsOptions options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public override async Task ExecuteAsync()
|
||||
{
|
||||
// Get data service
|
||||
var dataService = Container.Instance.Get<DataService>();
|
||||
|
||||
// Get guilds
|
||||
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
|
||||
|
||||
// Order guilds
|
||||
guilds = guilds.OrderBy(g => g.Name).ToArray();
|
||||
|
||||
// Print result
|
||||
foreach (var guild in guilds)
|
||||
Console.WriteLine($"{guild.Id} | {guild.Name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("export", HelpText = "Export channel.")]
|
||||
public class ExportChannelOptions : ExportOptions
|
||||
{
|
||||
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
|
||||
public string ChannelId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("exportdm", HelpText = "Export all direct message channels.")]
|
||||
public class ExportDirectMessagesOptions : ExportOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("exportguild", HelpText = "Export all channels within a given guild.")]
|
||||
public class ExportGuildOptions : ExportOptions
|
||||
{
|
||||
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
|
||||
public string GuildId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System;
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
public abstract class ExportOptions : TokenOptions
|
||||
{
|
||||
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
|
||||
public ExportFormat ExportFormat { get; set; }
|
||||
|
||||
[Option('o', "output", Default = null, HelpText = "Output file or directory path.")]
|
||||
public string OutputPath { get; set; }
|
||||
|
||||
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
|
||||
public DateTime? After { get; set; }
|
||||
|
||||
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
|
||||
public DateTime? Before { get; set; }
|
||||
|
||||
[Option('p', "partition", Default = null, HelpText = "Split output into partitions limited to this number of messages.")]
|
||||
public int? PartitionLimit { get; set; }
|
||||
|
||||
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
|
||||
public string DateFormat { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("channels", HelpText = "Get the list of channels in the given guild.")]
|
||||
public class GetChannelsOptions : TokenOptions
|
||||
{
|
||||
[Option('g', "guild", Required = true, HelpText = "Guild ID.")]
|
||||
public string GuildId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("dm", HelpText = "Get the list of direct message channels.")]
|
||||
public class GetDirectMessageChannelsOptions : TokenOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using CommandLine;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
[Verb("guilds", HelpText = "Get the list of accessible guilds.")]
|
||||
public class GetGuildsOptions : TokenOptions
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using CommandLine;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||
{
|
||||
public abstract class TokenOptions
|
||||
{
|
||||
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
|
||||
public string TokenValue { get; set; }
|
||||
|
||||
[Option('b', "bot", Default = false, HelpText = "Whether this authorization token belongs to a bot.")]
|
||||
public bool IsBotToken { get; set; }
|
||||
|
||||
public AuthToken GetToken() => new AuthToken(IsBotToken ? AuthTokenType.Bot : AuthTokenType.User, TokenValue);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Verbs
|
||||
{
|
||||
public abstract class Verb<TOptions>
|
||||
{
|
||||
protected TOptions Options { get; }
|
||||
|
||||
protected Verb(TOptions options)
|
||||
{
|
||||
Options = options;
|
||||
}
|
||||
|
||||
public abstract Task ExecuteAsync();
|
||||
|
||||
public virtual void Execute() => ExecuteAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Sprache" Version="2.2.0" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
internal class AggregateMatcher<T> : IMatcher<T>
|
||||
{
|
||||
private readonly IReadOnlyList<IMatcher<T>> _matchers;
|
||||
|
||||
public AggregateMatcher(IReadOnlyList<IMatcher<T>> matchers)
|
||||
{
|
||||
_matchers = matchers;
|
||||
}
|
||||
|
||||
public AggregateMatcher(params IMatcher<T>[] matchers)
|
||||
: this((IReadOnlyList<IMatcher<T>>)matchers)
|
||||
{
|
||||
}
|
||||
|
||||
public ParsedMatch<T> Match(StringPart stringPart)
|
||||
{
|
||||
ParsedMatch<T> earliestMatch = null;
|
||||
|
||||
// Try to match the input with each matcher and get the match with the lowest start index
|
||||
foreach (var matcher in _matchers)
|
||||
{
|
||||
// Try to match
|
||||
var match = matcher.Match(stringPart);
|
||||
|
||||
// If there's no match - continue
|
||||
if (match == null)
|
||||
continue;
|
||||
|
||||
// If this match is earlier than previous earliest - replace
|
||||
if (earliestMatch == null || match.StringPart.StartIndex < earliestMatch.StringPart.StartIndex)
|
||||
earliestMatch = match;
|
||||
|
||||
// If the earliest match starts at the very beginning - break,
|
||||
// because it's impossible to find a match earlier than that
|
||||
if (earliestMatch.StringPart.StartIndex == stringPart.StartIndex)
|
||||
break;
|
||||
}
|
||||
|
||||
return earliestMatch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
internal static class Extensions
|
||||
{
|
||||
public static StringPart Shrink(this StringPart stringPart, int newStartIndex, int newLength) =>
|
||||
new StringPart(stringPart.Target, newStartIndex, newLength);
|
||||
|
||||
public static StringPart Shrink(this StringPart stringPart, int newStartIndex) =>
|
||||
stringPart.Shrink(newStartIndex, stringPart.EndIndex - newStartIndex);
|
||||
|
||||
public static StringPart Shrink(this StringPart stringPart, Capture capture) =>
|
||||
stringPart.Shrink(capture.Index, capture.Length);
|
||||
|
||||
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, StringPart stringPart,
|
||||
Func<StringPart, T> fallbackTransform)
|
||||
{
|
||||
// Loop through segments divided by individual matches
|
||||
var currentIndex = stringPart.StartIndex;
|
||||
while (currentIndex < stringPart.EndIndex)
|
||||
{
|
||||
// Find a match within this segment
|
||||
var match = matcher.Match(stringPart.Shrink(currentIndex, stringPart.EndIndex - currentIndex));
|
||||
|
||||
// If there's no match - break
|
||||
if (match == null)
|
||||
break;
|
||||
|
||||
// If this match doesn't start immediately at current index - transform and yield fallback first
|
||||
if (match.StringPart.StartIndex > currentIndex)
|
||||
{
|
||||
var fallbackPart = stringPart.Shrink(currentIndex, match.StringPart.StartIndex - currentIndex);
|
||||
yield return new ParsedMatch<T>(fallbackPart, fallbackTransform(fallbackPart));
|
||||
}
|
||||
|
||||
// Yield match
|
||||
yield return match;
|
||||
|
||||
// Shift current index to the end of the match
|
||||
currentIndex = match.StringPart.StartIndex + match.StringPart.Length;
|
||||
}
|
||||
|
||||
// If EOL wasn't reached - transform and yield remaining part as fallback
|
||||
if (currentIndex < stringPart.EndIndex)
|
||||
{
|
||||
var fallbackPart = stringPart.Shrink(currentIndex);
|
||||
yield return new ParsedMatch<T>(fallbackPart, fallbackTransform(fallbackPart));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Sprache;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
|
||||
internal static class Grammar
|
||||
{
|
||||
/* Formatting */
|
||||
|
||||
// Capture until the earliest double asterisk not followed by an asterisk
|
||||
private static readonly Parser<Node> BoldFormattedNode =
|
||||
Parse.RegexMatch(new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "**", TextFormatting.Bold, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Capture until the earliest single asterisk not preceded or followed by an asterisk
|
||||
// Can't have whitespace right after opening or right before closing asterisk
|
||||
private static readonly Parser<Node> ItalicFormattedNode =
|
||||
Parse.RegexMatch(new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "*", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Can't have underscores inside
|
||||
// Can't have word characters right after closing underscore
|
||||
private static readonly Parser<Node> ItalicAltFormattedNode =
|
||||
Parse.RegexMatch(new Regex("_([^_]+?)_(?!\\w)", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "_", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Treated as a separate entity for simplicity
|
||||
// Capture until the earliest triple asterisk not preceded or followed by an asterisk
|
||||
private static readonly Parser<Node> ItalicBoldFormattedNode =
|
||||
Parse.RegexMatch(new Regex("\\*(\\*\\*(?:.+?)\\*\\*)\\*(?!\\*)", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "*", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Capture until the earliest double underscore not followed by an underscore
|
||||
private static readonly Parser<Node> UnderlineFormattedNode =
|
||||
Parse.RegexMatch(new Regex("__(.+?)__(?!_)", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "__", TextFormatting.Underline, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Treated as a separate entity for simplicity
|
||||
// Capture until the earliest triple underscore not preceded or followed by an underscore
|
||||
private static readonly Parser<Node> ItalicUnderlineFormattedNode =
|
||||
Parse.RegexMatch(new Regex("_(__(?:.+?)__)_(?!_)", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "_", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Strikethrough is safe
|
||||
private static readonly Parser<Node> StrikethroughFormattedNode =
|
||||
Parse.RegexMatch(new Regex("~~(.+?)~~", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "~~", TextFormatting.Strikethrough, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Spoiler is safe
|
||||
private static readonly Parser<Node> SpoilerFormattedNode =
|
||||
Parse.RegexMatch(new Regex("\\|\\|(.+?)\\|\\|", RegexOptions.Singleline))
|
||||
.Select(m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, BuildTree(m.Groups[1].Value)));
|
||||
|
||||
// Combinator, order matters
|
||||
private static readonly Parser<Node> AnyFormattedNode =
|
||||
ItalicBoldFormattedNode.Or(ItalicUnderlineFormattedNode)
|
||||
.Or(BoldFormattedNode).Or(ItalicFormattedNode)
|
||||
.Or(UnderlineFormattedNode).Or(ItalicAltFormattedNode)
|
||||
.Or(StrikethroughFormattedNode).Or(SpoilerFormattedNode);
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
// Can't have backticks inside and surrounding whitespace is trimmed
|
||||
private static readonly Parser<Node> InlineCodeBlockNode =
|
||||
Parse.RegexMatch(new Regex("`\\s*([^`]+?)\\s*`", RegexOptions.Singleline))
|
||||
.Select(m => new InlineCodeBlockNode(m.Value, m.Groups[1].Value));
|
||||
|
||||
// The first word is a language identifier if it's the only word followed by a newline, the rest is code
|
||||
private static readonly Parser<Node> MultilineCodeBlockNode =
|
||||
Parse.RegexMatch(new Regex("```(?:(\\w*?)?(?:\\s*?\\n))?(.+?)```", RegexOptions.Singleline))
|
||||
.Select(m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value));
|
||||
|
||||
// Combinator, order matters
|
||||
private static readonly Parser<Node> AnyCodeBlockNode = MultilineCodeBlockNode.Or(InlineCodeBlockNode);
|
||||
|
||||
/* Mentions */
|
||||
|
||||
// @everyone or @here
|
||||
private static readonly Parser<Node> MetaMentionNode = Parse.RegexMatch("@(everyone|here)")
|
||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Meta));
|
||||
|
||||
// <@123456> or <@!123456>
|
||||
private static readonly Parser<Node> UserMentionNode = Parse.RegexMatch("<@!?(\\d+)>")
|
||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.User));
|
||||
|
||||
// <#123456>
|
||||
private static readonly Parser<Node> ChannelMentionNode = Parse.RegexMatch("<#(\\d+)>")
|
||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Channel));
|
||||
|
||||
// <@&123456>
|
||||
private static readonly Parser<Node> RoleMentionNode = Parse.RegexMatch("<@&(\\d+)>")
|
||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Role));
|
||||
|
||||
// Combinator, order matters
|
||||
private static readonly Parser<Node> AnyMentionNode =
|
||||
MetaMentionNode.Or(UserMentionNode).Or(ChannelMentionNode).Or(RoleMentionNode);
|
||||
|
||||
/* Emojis */
|
||||
|
||||
// Matches all standard unicode emojis
|
||||
private static readonly Parser<Node> StandardEmojiNode = Parse.RegexMatch(
|
||||
"([\\u2700-\\u27bf]|" +
|
||||
"(?:\\ud83c[\\udde6-\\uddff]){2}|" +
|
||||
"[\\ud800-\\udbff][\\udc00-\\udfff]|" +
|
||||
"[\\u0023-\\u0039]\\u20e3|" +
|
||||
"\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|\\ud83c[\\udd70-\\udd71]|\\ud83c[\\udd7e-\\udd7f]|\\ud83c\\udd8e|\\ud83c[\\udd91-\\udd9a]|\\ud83c[\\udde6-\\uddff]|" +
|
||||
"\\ud83c[\\ude01-\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|\\ud83c[\\ude32-\\ude3a]|\\ud83c[\\ude50-\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|" +
|
||||
"\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|" +
|
||||
"\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff])")
|
||||
.Select(m => new EmojiNode(m.Value, m.Groups[1].Value));
|
||||
|
||||
// <:lul:123456> or <a:lul:123456>
|
||||
private static readonly Parser<Node> CustomEmojiNode = Parse.RegexMatch("<(a)?:(.+?):(\\d+)>")
|
||||
.Select(m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, m.Groups[1].Value.IsNotBlank()));
|
||||
|
||||
// Combinator, order matters
|
||||
private static readonly Parser<Node> AnyEmojiNode = StandardEmojiNode.Or(CustomEmojiNode);
|
||||
|
||||
/* Links */
|
||||
|
||||
// [title](link)
|
||||
private static readonly Parser<Node> TitledLinkNode = Parse.RegexMatch("\\[(.+?)\\]\\((.+?)\\)")
|
||||
.Select(m => new LinkNode(m.Value, m.Groups[2].Value, m.Groups[1].Value));
|
||||
|
||||
// Starts with http:// or https://, stops at the last non-whitespace character followed by whitespace or punctuation character
|
||||
private static readonly Parser<Node> AutoLinkNode = Parse.RegexMatch("(https?://\\S*[^\\.,:;\"\'\\s])")
|
||||
.Select(m => new LinkNode(m.Value, m.Groups[1].Value));
|
||||
|
||||
// Autolink surrounded by angular brackets
|
||||
private static readonly Parser<Node> HiddenLinkNode = Parse.RegexMatch("<(https?://\\S*[^\\.,:;\"\'\\s])>")
|
||||
.Select(m => new LinkNode(m.Value, m.Groups[1].Value));
|
||||
|
||||
// Combinator, order matters
|
||||
private static readonly Parser<Node> AnyLinkNode = TitledLinkNode.Or(HiddenLinkNode).Or(AutoLinkNode);
|
||||
|
||||
/* Text */
|
||||
|
||||
// Shrug is an exception and needs to be exempt from formatting
|
||||
private static readonly Parser<Node> ShrugTextNode =
|
||||
Parse.String("¯\\_(ツ)_/¯").Text().Select(s => new TextNode(s));
|
||||
|
||||
// Backslash escapes any following unicode surrogate pair
|
||||
private static readonly Parser<Node> EscapedSurrogateTextNode =
|
||||
from slash in Parse.Char('\\')
|
||||
from high in Parse.AnyChar.Where(char.IsHighSurrogate)
|
||||
from low in Parse.AnyChar
|
||||
let lexeme = $"{slash}{high}{low}"
|
||||
let text = $"{high}{low}"
|
||||
select new TextNode(lexeme, text);
|
||||
|
||||
// Backslash escapes any following non-whitespace character except for digits and latin letters
|
||||
private static readonly Parser<Node> EscapedTextNode =
|
||||
Parse.RegexMatch("\\\\([^a-zA-Z0-9\\s])").Select(m => new TextNode(m.Value, m.Groups[1].Value));
|
||||
|
||||
// Combinator, order matters
|
||||
private static readonly Parser<Node> AnyTextNode = ShrugTextNode.Or(EscapedSurrogateTextNode).Or(EscapedTextNode);
|
||||
|
||||
/* Aggregator and fallback */
|
||||
|
||||
// Any node recognized by above patterns
|
||||
private static readonly Parser<Node> AnyRecognizedNode = AnyFormattedNode.Or(AnyCodeBlockNode)
|
||||
.Or(AnyMentionNode).Or(AnyEmojiNode).Or(AnyLinkNode).Or(AnyTextNode);
|
||||
|
||||
// Any node not recognized by above patterns (treated as plain text)
|
||||
private static readonly Parser<Node> FallbackNode =
|
||||
Parse.AnyChar.Except(AnyRecognizedNode).AtLeastOnce().Text().Select(s => new TextNode(s));
|
||||
|
||||
// Any node
|
||||
private static readonly Parser<Node> AnyNode = AnyRecognizedNode.Or(FallbackNode);
|
||||
|
||||
// Entry point
|
||||
public static IReadOnlyList<Node> BuildTree(string input) => AnyNode.Many().Parse(input).ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
internal interface IMatcher<T>
|
||||
{
|
||||
ParsedMatch<T> Match(StringPart stringPart);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
internal class ParsedMatch<T>
|
||||
{
|
||||
public StringPart StringPart { get; }
|
||||
|
||||
public T Value { get; }
|
||||
|
||||
public ParsedMatch(StringPart stringPart, T value)
|
||||
{
|
||||
StringPart = stringPart;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
internal class RegexMatcher<T> : IMatcher<T>
|
||||
{
|
||||
private readonly Regex _regex;
|
||||
private readonly Func<StringPart, Match, T> _transform;
|
||||
|
||||
public RegexMatcher(Regex regex, Func<StringPart, Match, T> transform)
|
||||
{
|
||||
_regex = regex;
|
||||
_transform = transform;
|
||||
}
|
||||
|
||||
public RegexMatcher(Regex regex, Func<Match, T> transform)
|
||||
: this(regex, (p, m) => transform(m))
|
||||
{
|
||||
}
|
||||
|
||||
public ParsedMatch<T> Match(StringPart stringPart)
|
||||
{
|
||||
var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
// Overload regex.Match(string, int, int) doesn't take the whole string into account,
|
||||
// it effectively functions as a match check on a substring.
|
||||
// Which is super weird because regex.Match(string, int) takes the whole input in context.
|
||||
// So in order to properly account for ^/$ regex tokens, we need to make sure that
|
||||
// the expression also matches on the bigger part of the input.
|
||||
if (!_regex.IsMatch(stringPart.Target.Substring(0, stringPart.EndIndex), stringPart.StartIndex))
|
||||
return null;
|
||||
|
||||
var stringPartShrunk = stringPart.Shrink(match.Index, match.Length);
|
||||
return new ParsedMatch<T>(stringPartShrunk, _transform(stringPartShrunk, match));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
internal class StringMatcher<T> : IMatcher<T>
|
||||
{
|
||||
private readonly string _needle;
|
||||
private readonly StringComparison _comparison;
|
||||
private readonly Func<StringPart, T> _transform;
|
||||
|
||||
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T> transform)
|
||||
{
|
||||
_needle = needle;
|
||||
_comparison = comparison;
|
||||
_transform = transform;
|
||||
}
|
||||
|
||||
public StringMatcher(string needle, Func<StringPart, T> transform)
|
||||
: this(needle, StringComparison.Ordinal, transform)
|
||||
{
|
||||
}
|
||||
|
||||
public ParsedMatch<T> Match(StringPart stringPart)
|
||||
{
|
||||
var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
|
||||
|
||||
if (index >= 0)
|
||||
{
|
||||
var stringPartShrunk = stringPart.Shrink(index, _needle.Length);
|
||||
return new ParsedMatch<T>(stringPartShrunk, _transform(stringPartShrunk));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||
{
|
||||
internal class StringPart
|
||||
{
|
||||
public string Target { get; }
|
||||
|
||||
public int StartIndex { get; }
|
||||
|
||||
public int Length { get; }
|
||||
|
||||
public int EndIndex { get; }
|
||||
|
||||
public StringPart(string target, int startIndex, int length)
|
||||
{
|
||||
Target = target;
|
||||
StartIndex = startIndex;
|
||||
Length = length;
|
||||
EndIndex = startIndex + length;
|
||||
}
|
||||
|
||||
public StringPart(string target)
|
||||
: this(target, 0, target.Length)
|
||||
{
|
||||
}
|
||||
|
||||
public override string ToString() => Target.Substring(StartIndex, Length);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,237 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Core.Markdown.Internal;
|
||||
using DiscordChatExporter.Core.Markdown.Nodes;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
|
||||
public static class MarkdownParser
|
||||
{
|
||||
public static IReadOnlyList<Node> Parse(string input) => Grammar.BuildTree(input);
|
||||
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.Multiline;
|
||||
|
||||
/* Formatting */
|
||||
|
||||
// Capture any character until the earliest double asterisk not followed by an asterisk
|
||||
private static readonly IMatcher<Node> BoldFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Bold, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
|
||||
// Opening asterisk must not be followed by whitespace
|
||||
// Closing asterisk must not be preceded by whitespace
|
||||
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
// Capture any character until the earliest triple asterisk not followed by an asterisk
|
||||
private static readonly IMatcher<Node> ItalicBoldFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]), BoldFormattedNodeMatcher)));
|
||||
|
||||
// Capture any character except underscore until an underscore
|
||||
// Closing underscore must not be followed by a word character
|
||||
private static readonly IMatcher<Node> ItalicAltFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
// Capture any character until the earliest double underscore not followed by an underscore
|
||||
private static readonly IMatcher<Node> UnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Underline, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
// Capture any character until the earliest triple underscore not followed by an underscore
|
||||
private static readonly IMatcher<Node> ItalicUnderlineFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Italic, Parse(p.Shrink(m.Groups[1]), UnderlineFormattedNodeMatcher)));
|
||||
|
||||
// Capture any character until the earliest double tilde
|
||||
private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Strikethrough, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
// Capture any character until the earliest double pipe
|
||||
private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Spoiler, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
// Capture any character until the end of the line
|
||||
// Opening 'greater than' character must be followed by whitespace
|
||||
private static readonly IMatcher<Node> SingleLineQuoteNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("^>\\s(.+)\r?\n?", DefaultRegexOptions),
|
||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
// Capture any character until the end of the input
|
||||
// Opening 'greater than' characters must be followed by whitespace
|
||||
private static readonly IMatcher<Node> MultiLineQuoteNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(p, m) => new FormattedNode(TextFormatting.Quote, Parse(p.Shrink(m.Groups[1]))));
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
// Capture any character except backtick until a backtick
|
||||
// Blank lines at the beginning and end of content are trimmed
|
||||
private static readonly IMatcher<Node> InlineCodeBlockNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
m => new InlineCodeBlockNode(m.Groups[1].Value.Trim('\r', '\n')));
|
||||
|
||||
// Capture language identifier and then any character until the earliest triple backtick
|
||||
// Language identifier is one word immediately after opening backticks, followed immediately by newline
|
||||
// Blank lines at the beginning and end of content are trimmed
|
||||
private static readonly IMatcher<Node> MultiLineCodeBlockNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')));
|
||||
|
||||
/* Mentions */
|
||||
|
||||
// Capture @everyone
|
||||
private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>(
|
||||
"@everyone",
|
||||
p => new MentionNode("everyone", MentionType.Meta));
|
||||
|
||||
// Capture @here
|
||||
private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>(
|
||||
"@here",
|
||||
p => new MentionNode("here", MentionType.Meta));
|
||||
|
||||
// Capture <@123456> or <@!123456>
|
||||
private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.User));
|
||||
|
||||
// Capture <#123456>
|
||||
private static readonly IMatcher<Node> ChannelMentionNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("<#(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Channel));
|
||||
|
||||
// Capture <@&123456>
|
||||
private static readonly IMatcher<Node> RoleMentionNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("<@&(\\d+)>", DefaultRegexOptions),
|
||||
m => new MentionNode(m.Groups[1].Value, MentionType.Role));
|
||||
|
||||
/* Emojis */
|
||||
|
||||
// Capture any country flag emoji (two regional indicator surrogate pairs)
|
||||
// ... or "symbol/other" character
|
||||
// ... or surrogate pair
|
||||
// ... or digit followed by enclosing mark
|
||||
// (this does not match all emojis in Discord but it's reasonably accurate enough)
|
||||
private static readonly IMatcher<Node> StandardEmojiNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|\\p{So}|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
|
||||
m => new EmojiNode(m.Groups[1].Value));
|
||||
|
||||
// Capture <:lul:123456> or <a:lul:123456>
|
||||
private static readonly IMatcher<Node> CustomEmojiNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
|
||||
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsNullOrWhiteSpace()));
|
||||
|
||||
/* Links */
|
||||
|
||||
// Capture [title](link)
|
||||
private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value));
|
||||
|
||||
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
|
||||
private static readonly IMatcher<Node> AutoLinkNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[1].Value));
|
||||
|
||||
// Same as auto link but also surrounded by angular brackets
|
||||
private static readonly IMatcher<Node> HiddenLinkNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
|
||||
m => new LinkNode(m.Groups[1].Value));
|
||||
|
||||
/* Text */
|
||||
|
||||
// Capture the shrug emoticon
|
||||
// This escapes it from matching for formatting
|
||||
private static readonly IMatcher<Node> ShrugTextNodeMatcher = new StringMatcher<Node>(
|
||||
@"¯\_(ツ)_/¯",
|
||||
p => new TextNode(p.ToString()));
|
||||
|
||||
// Capture some specific emojis that don't get rendered
|
||||
// This escapes it from matching for emoji
|
||||
private static readonly IMatcher<Node> IgnoredEmojiTextNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value));
|
||||
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash
|
||||
// This escapes it from matching for emoji
|
||||
private static readonly IMatcher<Node> EscapedSymbolTextNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value));
|
||||
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
|
||||
// This escapes it from matching for formatting or other tokens
|
||||
private static readonly IMatcher<Node> EscapedCharacterTextNodeMatcher = new RegexMatcher<Node>(
|
||||
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
|
||||
m => new TextNode(m.Groups[1].Value));
|
||||
|
||||
// Combine all matchers into one
|
||||
// Matchers that have similar patterns are ordered from most specific to least specific
|
||||
private static readonly IMatcher<Node> AggregateNodeMatcher = new AggregateMatcher<Node>(
|
||||
// Escaped text
|
||||
ShrugTextNodeMatcher,
|
||||
IgnoredEmojiTextNodeMatcher,
|
||||
EscapedSymbolTextNodeMatcher,
|
||||
EscapedCharacterTextNodeMatcher,
|
||||
|
||||
// Formatting
|
||||
ItalicBoldFormattedNodeMatcher,
|
||||
ItalicUnderlineFormattedNodeMatcher,
|
||||
BoldFormattedNodeMatcher,
|
||||
ItalicFormattedNodeMatcher,
|
||||
UnderlineFormattedNodeMatcher,
|
||||
ItalicAltFormattedNodeMatcher,
|
||||
StrikethroughFormattedNodeMatcher,
|
||||
SpoilerFormattedNodeMatcher,
|
||||
MultiLineQuoteNodeMatcher,
|
||||
SingleLineQuoteNodeMatcher,
|
||||
|
||||
// Code blocks
|
||||
MultiLineCodeBlockNodeMatcher,
|
||||
InlineCodeBlockNodeMatcher,
|
||||
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
|
||||
// Links
|
||||
TitledLinkNodeMatcher,
|
||||
AutoLinkNodeMatcher,
|
||||
HiddenLinkNodeMatcher,
|
||||
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher);
|
||||
|
||||
private static readonly IMatcher<Node> MinimalAggregateNodeMatcher = new AggregateMatcher<Node>(
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher);
|
||||
|
||||
private static IReadOnlyList<Node> Parse(StringPart stringPart, IMatcher<Node> matcher) =>
|
||||
matcher.MatchAll(stringPart, p => new TextNode(p.ToString())).Select(r => r.Value).ToArray();
|
||||
|
||||
private static IReadOnlyList<Node> Parse(StringPart stringPart) => Parse(stringPart, AggregateNodeMatcher);
|
||||
|
||||
private static IReadOnlyList<Node> ParseMinimal(StringPart stringPart) => Parse(stringPart, MinimalAggregateNodeMatcher);
|
||||
|
||||
public static IReadOnlyList<Node> Parse(string input) => Parse(new StringPart(input));
|
||||
|
||||
public static IReadOnlyList<Node> ParseMinimal(string input) => ParseMinimal(new StringPart(input));
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
public abstract class Node
|
||||
{
|
||||
public string Lexeme { get; }
|
||||
|
||||
protected Node(string lexeme)
|
||||
{
|
||||
Lexeme = lexeme;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -1,6 +1,6 @@
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public class EmojiNode : Node
|
||||
{
|
||||
@@ -10,18 +10,17 @@ namespace DiscordChatExporter.Core.Markdown
|
||||
|
||||
public bool IsAnimated { get; }
|
||||
|
||||
public bool IsCustomEmoji => Id.IsNotBlank();
|
||||
public bool IsCustomEmoji => !Id.IsNullOrWhiteSpace();
|
||||
|
||||
public EmojiNode(string lexeme, string id, string name, bool isAnimated)
|
||||
: base(lexeme)
|
||||
public EmojiNode(string id, string name, bool isAnimated)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IsAnimated = isAnimated;
|
||||
}
|
||||
|
||||
public EmojiNode(string lexeme, string name)
|
||||
: this(lexeme, null, name, false)
|
||||
public EmojiNode(string name)
|
||||
: this(null, name, false)
|
||||
{
|
||||
}
|
||||
|
||||
+2
-6
@@ -1,19 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public class FormattedNode : Node
|
||||
{
|
||||
public string Token { get; }
|
||||
|
||||
public TextFormatting Formatting { get; }
|
||||
|
||||
public IReadOnlyList<Node> Children { get; }
|
||||
|
||||
public FormattedNode(string lexeme, string token, TextFormatting formatting, IReadOnlyList<Node> children)
|
||||
: base(lexeme)
|
||||
public FormattedNode(TextFormatting formatting, IReadOnlyList<Node> children)
|
||||
{
|
||||
Token = token;
|
||||
Formatting = formatting;
|
||||
Children = children;
|
||||
}
|
||||
+2
-3
@@ -1,11 +1,10 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public class InlineCodeBlockNode : Node
|
||||
{
|
||||
public string Code { get; }
|
||||
|
||||
public InlineCodeBlockNode(string lexeme, string code)
|
||||
: base(lexeme)
|
||||
public InlineCodeBlockNode(string code)
|
||||
{
|
||||
Code = code;
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public class LinkNode : Node
|
||||
{
|
||||
@@ -6,14 +6,14 @@
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public LinkNode(string lexeme, string url, string title)
|
||||
: base(lexeme)
|
||||
public LinkNode(string url, string title)
|
||||
{
|
||||
Url = url;
|
||||
Title = title;
|
||||
}
|
||||
|
||||
public LinkNode(string lexeme, string url) : this(lexeme, url, url)
|
||||
public LinkNode(string url)
|
||||
: this(url, url)
|
||||
{
|
||||
}
|
||||
|
||||
+2
-3
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public class MentionNode : Node
|
||||
{
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
public MentionType Type { get; }
|
||||
|
||||
public MentionNode(string lexeme, string id, MentionType type)
|
||||
: base(lexeme)
|
||||
public MentionNode(string id, MentionType type)
|
||||
{
|
||||
Id = id;
|
||||
Type = type;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public enum MentionType
|
||||
{
|
||||
+3
-4
@@ -1,13 +1,12 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public class MultilineCodeBlockNode : Node
|
||||
public class MultiLineCodeBlockNode : Node
|
||||
{
|
||||
public string Language { get; }
|
||||
|
||||
public string Code { get; }
|
||||
|
||||
public MultilineCodeBlockNode(string lexeme, string language, string code)
|
||||
: base(lexeme)
|
||||
public MultiLineCodeBlockNode(string language, string code)
|
||||
{
|
||||
Language = language;
|
||||
Code = code;
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public abstract class Node
|
||||
{
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public enum TextFormatting
|
||||
{
|
||||
@@ -6,6 +6,7 @@
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Spoiler
|
||||
Spoiler,
|
||||
Quote
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||
{
|
||||
public class TextNode : Node
|
||||
{
|
||||
public string Text { get; }
|
||||
|
||||
public TextNode(string text)
|
||||
{
|
||||
Text = text;
|
||||
}
|
||||
|
||||
public override string ToString() => Text;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace DiscordChatExporter.Core.Markdown
|
||||
{
|
||||
public class TextNode : Node
|
||||
{
|
||||
public string Text { get; }
|
||||
|
||||
public TextNode(string lexeme, string text)
|
||||
: base(lexeme)
|
||||
{
|
||||
Text = text;
|
||||
}
|
||||
|
||||
public TextNode(string text) : this(text, text)
|
||||
{
|
||||
}
|
||||
|
||||
public override string ToString() => Text;
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -1,11 +1,12 @@
|
||||
using System;
|
||||
using ByteSizeLib;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||
|
||||
public class Attachment
|
||||
public partial class Attachment
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
@@ -17,15 +18,11 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public bool IsImage => FileName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
FileName.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
FileName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
|
||||
FileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
|
||||
FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase);
|
||||
public bool IsImage { get; }
|
||||
|
||||
public ByteSize FileSize { get; }
|
||||
public FileSize FileSize { get; }
|
||||
|
||||
public Attachment(string id, int? width, int? height, string url, string fileName, ByteSize fileSize)
|
||||
public Attachment(string id, int? width, int? height, string url, string fileName, FileSize fileSize)
|
||||
{
|
||||
Id = id;
|
||||
Url = url;
|
||||
@@ -33,8 +30,21 @@ namespace DiscordChatExporter.Core.Models
|
||||
Height = height;
|
||||
FileName = fileName;
|
||||
FileSize = fileSize;
|
||||
|
||||
IsImage = GetIsImage(fileName);
|
||||
}
|
||||
|
||||
public override string ToString() => FileName;
|
||||
}
|
||||
|
||||
public partial class Attachment
|
||||
{
|
||||
private static readonly string[] ImageFileExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".bmp" };
|
||||
|
||||
public static bool GetIsImage(string fileName)
|
||||
{
|
||||
var fileExtension = Path.GetExtension(fileName);
|
||||
return ImageFileExtensions.Contains(fileExtension, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
public string Topic { get; }
|
||||
|
||||
public ChannelType Type { get; }
|
||||
|
||||
|
||||
public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type)
|
||||
{
|
||||
Id = id;
|
||||
+5
-5
@@ -9,21 +9,21 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public Channel Channel { get; }
|
||||
|
||||
public DateTime? From { get; }
|
||||
public DateTimeOffset? After { get; }
|
||||
|
||||
public DateTime? To { get; }
|
||||
public DateTimeOffset? Before { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public Mentionables Mentionables { get; }
|
||||
|
||||
public ChatLog(Guild guild, Channel channel, DateTime? from, DateTime? to,
|
||||
public ChatLog(Guild guild, Channel channel, DateTimeOffset? after, DateTimeOffset? before,
|
||||
IReadOnlyList<Message> messages, Mentionables mentionables)
|
||||
{
|
||||
Guild = guild;
|
||||
Channel = channel;
|
||||
From = from;
|
||||
To = to;
|
||||
After = after;
|
||||
Before = before;
|
||||
Messages = messages;
|
||||
Mentionables = mentionables;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public string Url { get; }
|
||||
|
||||
public DateTime? Timestamp { get; }
|
||||
public DateTimeOffset? Timestamp { get; }
|
||||
|
||||
public Color Color { get; }
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public EmbedFooter Footer { get; }
|
||||
|
||||
public Embed(string title, string url, DateTime? timestamp, Color color, EmbedAuthor author, string description,
|
||||
public Embed(string title, string url, DateTimeOffset? timestamp, Color color, EmbedAuthor author, string description,
|
||||
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
|
||||
{
|
||||
Title = title;
|
||||
+23
-21
@@ -14,31 +14,15 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public bool IsAnimated { get; }
|
||||
|
||||
public string ImageUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
// Custom emoji
|
||||
if (Id.IsNotBlank())
|
||||
{
|
||||
// Animated
|
||||
if (IsAnimated)
|
||||
return $"https://cdn.discordapp.com/emojis/{Id}.gif";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/emojis/{Id}.png";
|
||||
}
|
||||
|
||||
// Standard unicode emoji (via twemoji)
|
||||
return $"https://twemoji.maxcdn.com/2/72x72/{GetTwemojiName(Name)}.png";
|
||||
}
|
||||
}
|
||||
public string ImageUrl { get; }
|
||||
|
||||
public Emoji(string id, string name, bool isAnimated)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IsAnimated = isAnimated;
|
||||
|
||||
ImageUrl = GetImageUrl(id, name, isAnimated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +34,25 @@ namespace DiscordChatExporter.Core.Models
|
||||
yield return char.ConvertToUtf32(emoji, i);
|
||||
}
|
||||
|
||||
private static string GetTwemojiName(string emoji)
|
||||
=> GetCodePoints(emoji).Select(i => i.ToString("x")).JoinToString("-");
|
||||
private static string GetTwemojiName(string emoji) =>
|
||||
GetCodePoints(emoji).Select(i => i.ToString("x")).JoinToString("-");
|
||||
|
||||
public static string GetImageUrl(string id, string name, bool isAnimated)
|
||||
{
|
||||
// Custom emoji
|
||||
if (!id.IsNullOrWhiteSpace())
|
||||
{
|
||||
// Animated
|
||||
if (isAnimated)
|
||||
return $"https://cdn.discordapp.com/emojis/{id}.gif";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||
}
|
||||
|
||||
// Standard unicode emoji (via twemoji)
|
||||
var twemojiName = GetTwemojiName(name);
|
||||
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// Loosely based on https://github.com/omar/ByteSize (MIT license)
|
||||
|
||||
public struct FileSize
|
||||
{
|
||||
public const long BytesInKiloByte = 1024;
|
||||
public const long BytesInMegaByte = 1024 * BytesInKiloByte;
|
||||
public const long BytesInGigaByte = 1024 * BytesInMegaByte;
|
||||
public const long BytesInTeraByte = 1024 * BytesInGigaByte;
|
||||
public const long BytesInPetaByte = 1024 * BytesInTeraByte;
|
||||
|
||||
public const string ByteSymbol = "B";
|
||||
public const string KiloByteSymbol = "KB";
|
||||
public const string MegaByteSymbol = "MB";
|
||||
public const string GigaByteSymbol = "GB";
|
||||
public const string TeraByteSymbol = "TB";
|
||||
public const string PetaByteSymbol = "PB";
|
||||
|
||||
public double Bytes { get; }
|
||||
public double KiloBytes => Bytes / BytesInKiloByte;
|
||||
public double MegaBytes => Bytes / BytesInMegaByte;
|
||||
public double GigaBytes => Bytes / BytesInGigaByte;
|
||||
public double TeraBytes => Bytes / BytesInTeraByte;
|
||||
public double PetaBytes => Bytes / BytesInPetaByte;
|
||||
|
||||
public string LargestWholeNumberSymbol
|
||||
{
|
||||
get
|
||||
{
|
||||
// Absolute value is used to deal with negative values
|
||||
if (Math.Abs(PetaBytes) >= 1)
|
||||
return PetaByteSymbol;
|
||||
|
||||
if (Math.Abs(TeraBytes) >= 1)
|
||||
return TeraByteSymbol;
|
||||
|
||||
if (Math.Abs(GigaBytes) >= 1)
|
||||
return GigaByteSymbol;
|
||||
|
||||
if (Math.Abs(MegaBytes) >= 1)
|
||||
return MegaByteSymbol;
|
||||
|
||||
if (Math.Abs(KiloBytes) >= 1)
|
||||
return KiloByteSymbol;
|
||||
|
||||
return ByteSymbol;
|
||||
}
|
||||
}
|
||||
|
||||
public double LargestWholeNumberValue
|
||||
{
|
||||
get
|
||||
{
|
||||
// Absolute value is used to deal with negative values
|
||||
if (Math.Abs(PetaBytes) >= 1)
|
||||
return PetaBytes;
|
||||
|
||||
if (Math.Abs(TeraBytes) >= 1)
|
||||
return TeraBytes;
|
||||
|
||||
if (Math.Abs(GigaBytes) >= 1)
|
||||
return GigaBytes;
|
||||
|
||||
if (Math.Abs(MegaBytes) >= 1)
|
||||
return MegaBytes;
|
||||
|
||||
if (Math.Abs(KiloBytes) >= 1)
|
||||
return KiloBytes;
|
||||
|
||||
return Bytes;
|
||||
}
|
||||
}
|
||||
|
||||
public FileSize(double bytes)
|
||||
{
|
||||
Bytes = bytes;
|
||||
}
|
||||
|
||||
public override string ToString() => $"{LargestWholeNumberValue:0.##} {LargestWholeNumberSymbol}";
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -12,15 +12,15 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public string IconHash { get; }
|
||||
|
||||
public string IconUrl => IconHash.IsNotBlank()
|
||||
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
|
||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
public string IconUrl { get; }
|
||||
|
||||
public Guild(string id, string name, string iconHash)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
IconHash = iconHash;
|
||||
|
||||
IconUrl = GetIconUrl(id, iconHash);
|
||||
}
|
||||
|
||||
public override string ToString() => Name;
|
||||
@@ -28,6 +28,13 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public partial class Guild
|
||||
{
|
||||
public static string GetIconUrl(string id, string iconHash)
|
||||
{
|
||||
return !iconHash.IsNullOrWhiteSpace()
|
||||
? $"https://cdn.discordapp.com/icons/{id}/{iconHash}.png"
|
||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
||||
}
|
||||
|
||||
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
|
||||
}
|
||||
}
|
||||
+9
-5
@@ -15,9 +15,9 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime Timestamp { get; }
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public DateTime? EditedTimestamp { get; }
|
||||
public DateTimeOffset? EditedTimestamp { get; }
|
||||
|
||||
public string Content { get; }
|
||||
|
||||
@@ -29,9 +29,12 @@ namespace DiscordChatExporter.Core.Models
|
||||
|
||||
public IReadOnlyList<User> MentionedUsers { get; }
|
||||
|
||||
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
|
||||
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
||||
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
|
||||
public bool IsPinned { get; }
|
||||
|
||||
public Message(string id, string channelId, MessageType type, User author, DateTimeOffset timestamp,
|
||||
DateTimeOffset? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
||||
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers,
|
||||
bool isPinned)
|
||||
{
|
||||
Id = id;
|
||||
ChannelId = channelId;
|
||||
@@ -44,6 +47,7 @@ namespace DiscordChatExporter.Core.Models
|
||||
Embeds = embeds;
|
||||
Reactions = reactions;
|
||||
MentionedUsers = mentionedUsers;
|
||||
IsPinned = isPinned;
|
||||
}
|
||||
|
||||
public override string ToString() => Content;
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
public partial class Role
|
||||
{
|
||||
public static Role CreateDeletedRole(string id) =>
|
||||
new Role(id, "deleted-role");
|
||||
public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Models
|
||||
{
|
||||
// https://discordapp.com/developers/docs/topics/permissions#role-object
|
||||
|
||||
public partial class User
|
||||
{
|
||||
public string Id { get; }
|
||||
|
||||
public int Discriminator { get; }
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public string FullName { get; }
|
||||
|
||||
public string AvatarHash { get; }
|
||||
|
||||
public string AvatarUrl { get; }
|
||||
|
||||
public bool IsBot { get; }
|
||||
|
||||
public User(string id, int discriminator, string name, string avatarHash, bool isBot)
|
||||
{
|
||||
Id = id;
|
||||
Discriminator = discriminator;
|
||||
Name = name;
|
||||
AvatarHash = avatarHash;
|
||||
IsBot = isBot;
|
||||
|
||||
FullName = GetFullName(name, discriminator);
|
||||
AvatarUrl = GetAvatarUrl(id, discriminator, avatarHash);
|
||||
}
|
||||
|
||||
public override string ToString() => FullName;
|
||||
}
|
||||
|
||||
public partial class User
|
||||
{
|
||||
public static string GetFullName(string name, int discriminator) => $"{name}#{discriminator:0000}";
|
||||
|
||||
public static string GetAvatarUrl(string id, int discriminator, string avatarHash)
|
||||
{
|
||||
// Custom avatar
|
||||
if (!avatarHash.IsNullOrWhiteSpace())
|
||||
{
|
||||
// Animated
|
||||
if (avatarHash.StartsWith("a_", StringComparison.Ordinal))
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.gif";
|
||||
|
||||
// Non-animated
|
||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.png";
|
||||
}
|
||||
|
||||
// Default avatar
|
||||
return $"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png";
|
||||
}
|
||||
|
||||
public static User CreateUnknownUser(string id) => new User(id, 0, "Unknown", null, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Nodes;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Rendering
|
||||
{
|
||||
public class CsvChatLogRenderer : IChatLogRenderer
|
||||
{
|
||||
private readonly ChatLog _chatLog;
|
||||
private readonly string _dateFormat;
|
||||
|
||||
public CsvChatLogRenderer(ChatLog chatLog, string dateFormat)
|
||||
{
|
||||
_chatLog = chatLog;
|
||||
_dateFormat = dateFormat;
|
||||
}
|
||||
|
||||
private string FormatDate(DateTimeOffset date) =>
|
||||
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
private string FormatMarkdown(Node node)
|
||||
{
|
||||
// Text node
|
||||
if (node is TextNode textNode)
|
||||
{
|
||||
return textNode.Text;
|
||||
}
|
||||
|
||||
// Mention node
|
||||
if (node is MentionNode mentionNode)
|
||||
{
|
||||
// Meta mention node
|
||||
if (mentionNode.Type == MentionType.Meta)
|
||||
{
|
||||
return mentionNode.Id;
|
||||
}
|
||||
|
||||
// User mention node
|
||||
if (mentionNode.Type == MentionType.User)
|
||||
{
|
||||
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
|
||||
return $"@{user.Name}";
|
||||
}
|
||||
|
||||
// Channel mention node
|
||||
if (mentionNode.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
|
||||
return $"#{channel.Name}";
|
||||
}
|
||||
|
||||
// Role mention node
|
||||
if (mentionNode.Type == MentionType.Role)
|
||||
{
|
||||
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
|
||||
return $"@{role.Name}";
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji node
|
||||
if (node is EmojiNode emojiNode)
|
||||
{
|
||||
return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
|
||||
}
|
||||
|
||||
// Throw on unexpected nodes
|
||||
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
|
||||
}
|
||||
|
||||
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
|
||||
|
||||
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.ParseMinimal(markdown));
|
||||
|
||||
private async Task RenderFieldAsync(TextWriter writer, string value)
|
||||
{
|
||||
var encodedValue = value.Replace("\"", "\"\"");
|
||||
await writer.WriteAsync($"\"{encodedValue}\";");
|
||||
}
|
||||
|
||||
private async Task RenderMessageAsync(TextWriter writer, Message message)
|
||||
{
|
||||
// Author ID
|
||||
await RenderFieldAsync(writer, message.Author.Id);
|
||||
|
||||
// Author
|
||||
await RenderFieldAsync(writer, message.Author.FullName);
|
||||
|
||||
// Timestamp
|
||||
await RenderFieldAsync(writer, FormatDate(message.Timestamp));
|
||||
|
||||
// Content
|
||||
await RenderFieldAsync(writer, FormatMarkdown(message.Content));
|
||||
|
||||
// Attachments
|
||||
var formattedAttachments = message.Attachments.Select(a => a.Url).JoinToString(",");
|
||||
await RenderFieldAsync(writer, formattedAttachments);
|
||||
|
||||
// Reactions
|
||||
var formattedReactions = message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(",");
|
||||
await RenderFieldAsync(writer, formattedReactions);
|
||||
|
||||
// Line break
|
||||
await writer.WriteLineAsync();
|
||||
}
|
||||
|
||||
public async Task RenderAsync(TextWriter writer)
|
||||
{
|
||||
// Headers
|
||||
await writer.WriteLineAsync("AuthorID;Author;Date;Content;Attachments;Reactions;");
|
||||
|
||||
// Log
|
||||
foreach (var message in _chatLog.Messages)
|
||||
await RenderMessageAsync(writer, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\HtmlDark.css" />
|
||||
<EmbeddedResource Include="Resources\HtmlDark.html" />
|
||||
<EmbeddedResource Include="Resources\HtmlLight.css" />
|
||||
<EmbeddedResource Include="Resources\HtmlLight.html" />
|
||||
<EmbeddedResource Include="Resources\HtmlShared.css" />
|
||||
<EmbeddedResource Include="Resources\HtmlShared.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Scriban" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+4
-4
@@ -2,19 +2,19 @@
|
||||
using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
namespace DiscordChatExporter.Core.Rendering
|
||||
{
|
||||
public partial class ExportService
|
||||
public partial class HtmlChatLogRenderer
|
||||
{
|
||||
private class MessageGroup
|
||||
{
|
||||
public User Author { get; }
|
||||
|
||||
public DateTime Timestamp { get; }
|
||||
public DateTimeOffset Timestamp { get; }
|
||||
|
||||
public IReadOnlyList<Message> Messages { get; }
|
||||
|
||||
public MessageGroup(User author, DateTime timestamp, IReadOnlyList<Message> messages)
|
||||
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> messages)
|
||||
{
|
||||
Author = author;
|
||||
Timestamp = timestamp;
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using Scriban;
|
||||
using Scriban.Parsing;
|
||||
using Scriban.Runtime;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Rendering
|
||||
{
|
||||
public partial class HtmlChatLogRenderer
|
||||
{
|
||||
private class TemplateLoader : ITemplateLoader
|
||||
{
|
||||
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Rendering.Resources";
|
||||
|
||||
public string Load(string templatePath) =>
|
||||
Assembly.GetExecutingAssembly().GetManifestResourceString($"{ResourceRootNamespace}.{templatePath}");
|
||||
|
||||
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName) => templateName;
|
||||
|
||||
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) => Load(templatePath);
|
||||
|
||||
public ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath) =>
|
||||
new ValueTask<string>(Load(templatePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Nodes;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Scriban;
|
||||
using Scriban.Runtime;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Rendering
|
||||
{
|
||||
public partial class HtmlChatLogRenderer : IChatLogRenderer
|
||||
{
|
||||
private readonly ChatLog _chatLog;
|
||||
private readonly string _themeName;
|
||||
private readonly string _dateFormat;
|
||||
|
||||
public HtmlChatLogRenderer(ChatLog chatLog, string themeName, string dateFormat)
|
||||
{
|
||||
_chatLog = chatLog;
|
||||
_themeName = themeName;
|
||||
_dateFormat = dateFormat;
|
||||
}
|
||||
|
||||
private string HtmlEncode(string s) => WebUtility.HtmlEncode(s);
|
||||
|
||||
private string FormatDate(DateTimeOffset date) =>
|
||||
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages) =>
|
||||
messages.GroupContiguous((buffer, message) =>
|
||||
{
|
||||
// Break group if the author changed
|
||||
if (buffer.Last().Author.Id != message.Author.Id)
|
||||
return false;
|
||||
|
||||
// Break group if last message was more than 7 minutes ago
|
||||
if ((message.Timestamp - buffer.Last().Timestamp).TotalMinutes > 7)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
|
||||
|
||||
private string FormatMarkdown(Node node, bool isJumbo)
|
||||
{
|
||||
// Text node
|
||||
if (node is TextNode textNode)
|
||||
{
|
||||
// Return HTML-encoded text
|
||||
return HtmlEncode(textNode.Text);
|
||||
}
|
||||
|
||||
// Formatted node
|
||||
if (node is FormattedNode formattedNode)
|
||||
{
|
||||
// Recursively get inner html
|
||||
var innerHtml = FormatMarkdown(formattedNode.Children, false);
|
||||
|
||||
// Bold
|
||||
if (formattedNode.Formatting == TextFormatting.Bold)
|
||||
return $"<strong>{innerHtml}</strong>";
|
||||
|
||||
// Italic
|
||||
if (formattedNode.Formatting == TextFormatting.Italic)
|
||||
return $"<em>{innerHtml}</em>";
|
||||
|
||||
// Underline
|
||||
if (formattedNode.Formatting == TextFormatting.Underline)
|
||||
return $"<u>{innerHtml}</u>";
|
||||
|
||||
// Strikethrough
|
||||
if (formattedNode.Formatting == TextFormatting.Strikethrough)
|
||||
return $"<s>{innerHtml}</s>";
|
||||
|
||||
// Spoiler
|
||||
if (formattedNode.Formatting == TextFormatting.Spoiler)
|
||||
return $"<span class=\"spoiler\">{innerHtml}</span>";
|
||||
|
||||
// Quote
|
||||
if (formattedNode.Formatting == TextFormatting.Quote)
|
||||
return $"<div class=\"quote\">{innerHtml}</div>";
|
||||
}
|
||||
|
||||
// Inline code block node
|
||||
if (node is InlineCodeBlockNode inlineCodeBlockNode)
|
||||
{
|
||||
return $"<span class=\"pre pre--inline\">{HtmlEncode(inlineCodeBlockNode.Code)}</span>";
|
||||
}
|
||||
|
||||
// Multi-line code block node
|
||||
if (node is MultiLineCodeBlockNode multilineCodeBlockNode)
|
||||
{
|
||||
// Set CSS class for syntax highlighting
|
||||
var highlightCssClass = !multilineCodeBlockNode.Language.IsNullOrWhiteSpace()
|
||||
? $"language-{multilineCodeBlockNode.Language}"
|
||||
: "nohighlight";
|
||||
|
||||
return $"<div class=\"pre pre--multiline {highlightCssClass}\">{HtmlEncode(multilineCodeBlockNode.Code)}</div>";
|
||||
}
|
||||
|
||||
// Mention node
|
||||
if (node is MentionNode mentionNode)
|
||||
{
|
||||
// Meta mention node
|
||||
if (mentionNode.Type == MentionType.Meta)
|
||||
{
|
||||
return $"<span class=\"mention\">@{HtmlEncode(mentionNode.Id)}</span>";
|
||||
}
|
||||
|
||||
// User mention node
|
||||
if (mentionNode.Type == MentionType.User)
|
||||
{
|
||||
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
|
||||
return $"<span class=\"mention\" title=\"{HtmlEncode(user.FullName)}\">@{HtmlEncode(user.Name)}</span>";
|
||||
}
|
||||
|
||||
// Channel mention node
|
||||
if (mentionNode.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
|
||||
return $"<span class=\"mention\">#{HtmlEncode(channel.Name)}</span>";
|
||||
}
|
||||
|
||||
// Role mention node
|
||||
if (mentionNode.Type == MentionType.Role)
|
||||
{
|
||||
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
|
||||
return $"<span class=\"mention\">@{HtmlEncode(role.Name)}</span>";
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji node
|
||||
if (node is EmojiNode emojiNode)
|
||||
{
|
||||
// Get emoji image URL
|
||||
var emojiImageUrl = Emoji.GetImageUrl(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated);
|
||||
|
||||
// Make emoji large if it's jumbo
|
||||
var jumboableCssClass = isJumbo ? "emoji--large" : null;
|
||||
|
||||
return $"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />";
|
||||
}
|
||||
|
||||
// Link node
|
||||
if (node is LinkNode linkNode)
|
||||
{
|
||||
// Extract message ID if the link points to a Discord message
|
||||
var linkedMessageId = Regex.Match(linkNode.Url, "^https?://discordapp.com/channels/.*?/(\\d+)/?$").Groups[1].Value;
|
||||
|
||||
return linkedMessageId.IsNullOrWhiteSpace()
|
||||
? $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\">{HtmlEncode(linkNode.Title)}</a>"
|
||||
: $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">{HtmlEncode(linkNode.Title)}</a>";
|
||||
}
|
||||
|
||||
// Throw on unexpected nodes
|
||||
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
|
||||
}
|
||||
|
||||
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
|
||||
{
|
||||
// Emojis are jumbo if all top-level nodes are emoji nodes or whitespace text nodes
|
||||
var isJumbo = isTopLevel && nodes.All(n => n is EmojiNode || n is TextNode textNode && textNode.Text.IsNullOrWhiteSpace());
|
||||
|
||||
return nodes.Select(n => FormatMarkdown(n, isJumbo)).JoinToString("");
|
||||
}
|
||||
|
||||
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown), true);
|
||||
|
||||
public async Task RenderAsync(TextWriter writer)
|
||||
{
|
||||
// Create template loader
|
||||
var loader = new TemplateLoader();
|
||||
|
||||
// Get template
|
||||
var templateCode = loader.Load($"Html{_themeName}.html");
|
||||
var template = Template.Parse(templateCode);
|
||||
|
||||
// Create template context
|
||||
var context = new TemplateContext
|
||||
{
|
||||
TemplateLoader = loader,
|
||||
MemberRenamer = m => m.Name,
|
||||
MemberFilter = m => true,
|
||||
LoopLimit = int.MaxValue,
|
||||
StrictVariables = true
|
||||
};
|
||||
|
||||
// Create template model
|
||||
var model = new ScriptObject();
|
||||
model.SetValue("Model", _chatLog, true);
|
||||
model.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
|
||||
model.Import(nameof(FormatDate), new Func<DateTimeOffset, string>(FormatDate));
|
||||
model.Import(nameof(FormatMarkdown), new Func<string, string>(FormatMarkdown));
|
||||
context.PushGlobal(model);
|
||||
|
||||
// Configure output
|
||||
context.PushOutput(new TextWriterOutput(writer));
|
||||
|
||||
// HACK: Render output in a separate thread
|
||||
// (even though Scriban has async API, it still makes a lot of blocking CPU-bound calls)
|
||||
await Task.Run(async () => await context.EvaluateAsync(template.Page));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DiscordChatExporter.Core.Rendering
|
||||
{
|
||||
public interface IChatLogRenderer
|
||||
{
|
||||
Task RenderAsync(TextWriter writer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Nodes;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Rendering
|
||||
{
|
||||
public class PlainTextChatLogRenderer : IChatLogRenderer
|
||||
{
|
||||
private readonly ChatLog _chatLog;
|
||||
private readonly string _dateFormat;
|
||||
|
||||
public PlainTextChatLogRenderer(ChatLog chatLog, string dateFormat)
|
||||
{
|
||||
_chatLog = chatLog;
|
||||
_dateFormat = dateFormat;
|
||||
}
|
||||
|
||||
private string FormatDate(DateTimeOffset date) =>
|
||||
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
private string FormatDateRange(DateTimeOffset? after, DateTimeOffset? before)
|
||||
{
|
||||
// Both 'after' and 'before'
|
||||
if (after != null && before != null)
|
||||
return $"{FormatDate(after.Value)} to {FormatDate(before.Value)}";
|
||||
|
||||
// Just 'after'
|
||||
if (after != null)
|
||||
return $"after {FormatDate(after.Value)}";
|
||||
|
||||
// Just 'before'
|
||||
if (before != null)
|
||||
return $"before {FormatDate(before.Value)}";
|
||||
|
||||
// Neither
|
||||
return null;
|
||||
}
|
||||
|
||||
private string FormatMarkdown(Node node)
|
||||
{
|
||||
// Text node
|
||||
if (node is TextNode textNode)
|
||||
{
|
||||
return textNode.Text;
|
||||
}
|
||||
|
||||
// Mention node
|
||||
if (node is MentionNode mentionNode)
|
||||
{
|
||||
// Meta mention node
|
||||
if (mentionNode.Type == MentionType.Meta)
|
||||
{
|
||||
return mentionNode.Id;
|
||||
}
|
||||
|
||||
// User mention node
|
||||
if (mentionNode.Type == MentionType.User)
|
||||
{
|
||||
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
|
||||
return $"@{user.Name}";
|
||||
}
|
||||
|
||||
// Channel mention node
|
||||
if (mentionNode.Type == MentionType.Channel)
|
||||
{
|
||||
var channel = _chatLog.Mentionables.GetChannel(mentionNode.Id);
|
||||
return $"#{channel.Name}";
|
||||
}
|
||||
|
||||
// Role mention node
|
||||
if (mentionNode.Type == MentionType.Role)
|
||||
{
|
||||
var role = _chatLog.Mentionables.GetRole(mentionNode.Id);
|
||||
return $"@{role.Name}";
|
||||
}
|
||||
}
|
||||
|
||||
// Emoji node
|
||||
if (node is EmojiNode emojiNode)
|
||||
{
|
||||
return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
|
||||
}
|
||||
|
||||
// Throw on unexpected nodes
|
||||
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
|
||||
}
|
||||
|
||||
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
|
||||
|
||||
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.ParseMinimal(markdown));
|
||||
|
||||
private async Task RenderMessageHeaderAsync(TextWriter writer, Message message)
|
||||
{
|
||||
// Timestamp
|
||||
await writer.WriteAsync($"[{FormatDate(message.Timestamp)}]");
|
||||
|
||||
// Author
|
||||
await writer.WriteAsync($" {message.Author.FullName}");
|
||||
|
||||
// Whether the message is pinned
|
||||
if (message.IsPinned)
|
||||
await writer.WriteAsync(" (pinned)");
|
||||
|
||||
await writer.WriteLineAsync();
|
||||
}
|
||||
|
||||
private async Task RenderAttachmentsAsync(TextWriter writer, IReadOnlyList<Attachment> attachments)
|
||||
{
|
||||
if (attachments.Any())
|
||||
{
|
||||
await writer.WriteLineAsync("{Attachments}");
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
await writer.WriteLineAsync(attachment.Url);
|
||||
|
||||
await writer.WriteLineAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenderEmbedsAsync(TextWriter writer, IReadOnlyList<Embed> embeds)
|
||||
{
|
||||
foreach (var embed in embeds)
|
||||
{
|
||||
await writer.WriteLineAsync("{Embed}");
|
||||
|
||||
// Author name
|
||||
if (!(embed.Author?.Name).IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(embed.Author?.Name);
|
||||
|
||||
// URL
|
||||
if (!embed.Url.IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(embed.Url);
|
||||
|
||||
// Title
|
||||
if (!embed.Title.IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(FormatMarkdown(embed.Title));
|
||||
|
||||
// Description
|
||||
if (!embed.Description.IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(FormatMarkdown(embed.Description));
|
||||
|
||||
// Fields
|
||||
foreach (var field in embed.Fields)
|
||||
{
|
||||
// Name
|
||||
if (!field.Name.IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(field.Name);
|
||||
|
||||
// Value
|
||||
if (!field.Value.IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(field.Value);
|
||||
}
|
||||
|
||||
// Thumbnail URL
|
||||
if (!(embed.Thumbnail?.Url).IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(embed.Thumbnail?.Url);
|
||||
|
||||
// Image URL
|
||||
if (!(embed.Image?.Url).IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(embed.Image?.Url);
|
||||
|
||||
// Footer text
|
||||
if (!(embed.Footer?.Text).IsNullOrWhiteSpace())
|
||||
await writer.WriteLineAsync(embed.Footer?.Text);
|
||||
|
||||
await writer.WriteLineAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenderReactionsAsync(TextWriter writer, IReadOnlyList<Reaction> reactions)
|
||||
{
|
||||
if (reactions.Any())
|
||||
{
|
||||
await writer.WriteLineAsync("{Reactions}");
|
||||
|
||||
foreach (var reaction in reactions)
|
||||
{
|
||||
await writer.WriteAsync(reaction.Emoji.Name);
|
||||
|
||||
if (reaction.Count > 1)
|
||||
await writer.WriteAsync($" ({reaction.Count})");
|
||||
|
||||
await writer.WriteAsync(" ");
|
||||
}
|
||||
|
||||
await writer.WriteLineAsync();
|
||||
await writer.WriteLineAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RenderMessageAsync(TextWriter writer, Message message)
|
||||
{
|
||||
// Header
|
||||
await RenderMessageHeaderAsync(writer, message);
|
||||
|
||||
// Content
|
||||
await writer.WriteLineAsync(FormatMarkdown(message.Content));
|
||||
|
||||
// Separator
|
||||
await writer.WriteLineAsync();
|
||||
|
||||
// Attachments
|
||||
await RenderAttachmentsAsync(writer, message.Attachments);
|
||||
|
||||
// Embeds
|
||||
await RenderEmbedsAsync(writer, message.Embeds);
|
||||
|
||||
// Reactions
|
||||
await RenderReactionsAsync(writer, message.Reactions);
|
||||
}
|
||||
|
||||
public async Task RenderAsync(TextWriter writer)
|
||||
{
|
||||
// Metadata
|
||||
await writer.WriteLineAsync('='.Repeat(62));
|
||||
await writer.WriteLineAsync($"Guild: {_chatLog.Guild.Name}");
|
||||
await writer.WriteLineAsync($"Channel: {_chatLog.Channel.Name}");
|
||||
await writer.WriteLineAsync($"Topic: {_chatLog.Channel.Topic}");
|
||||
await writer.WriteLineAsync($"Messages: {_chatLog.Messages.Count:N0}");
|
||||
await writer.WriteLineAsync($"Range: {FormatDateRange(_chatLog.After, _chatLog.Before)}");
|
||||
await writer.WriteLineAsync('='.Repeat(62));
|
||||
await writer.WriteLineAsync();
|
||||
|
||||
// Log
|
||||
foreach (var message in _chatLog.Messages)
|
||||
await RenderMessageAsync(writer, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -13,6 +13,10 @@ a {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.quote {
|
||||
border-color: #4f545c;
|
||||
}
|
||||
|
||||
.pre {
|
||||
background-color: #2f3136 !important;
|
||||
}
|
||||
@@ -54,6 +58,14 @@ a {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.chatlog__message--highlighted {
|
||||
background-color: rgba(114, 137, 218, 0.2) !important;
|
||||
}
|
||||
|
||||
.chatlog__message--pinned {
|
||||
background-color: rgba(249, 168, 37, 0.05);
|
||||
}
|
||||
|
||||
.chatlog__edited-timestamp {
|
||||
color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{~ ThemeStyleSheet = include "HtmlDark.css" ~}}
|
||||
{{~ HighlightJsStyleName = "solarized-dark" ~}}
|
||||
{{~ include "HtmlShared.html" ~}}
|
||||
+19
-5
@@ -2,7 +2,8 @@
|
||||
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
color: #747f8d;
|
||||
color: #23262a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -13,6 +14,10 @@ a {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.quote {
|
||||
border-color: #c7ccd1;
|
||||
}
|
||||
|
||||
.pre {
|
||||
background-color: #f9f9f9 !important;
|
||||
}
|
||||
@@ -48,15 +53,24 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__author-name {
|
||||
font-weight: 600;
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
.chatlog__timestamp {
|
||||
color: #99aab5;
|
||||
color: #747f8d;
|
||||
}
|
||||
|
||||
.chatlog__message--highlighted {
|
||||
background-color: rgba(114, 137, 218, 0.2) !important;
|
||||
}
|
||||
|
||||
.chatlog__message--pinned {
|
||||
background-color: rgba(249, 168, 37, 0.05);
|
||||
}
|
||||
|
||||
.chatlog__edited-timestamp {
|
||||
color: #99aab5;
|
||||
color: #747f8d;
|
||||
}
|
||||
|
||||
.chatlog__embed-content-container {
|
||||
@@ -89,7 +103,7 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__embed-footer {
|
||||
color: rgba(79, 83, 91, 0.4);
|
||||
color: rgba(79, 83, 91, 0.6);
|
||||
}
|
||||
|
||||
.chatlog__reaction {
|
||||
@@ -97,5 +111,5 @@ a {
|
||||
}
|
||||
|
||||
.chatlog__reaction-count {
|
||||
color: #99aab5;
|
||||
color: #747f8d;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{{~ ThemeStyleSheet = include "HtmlLight.css" ~}}
|
||||
{{~ HighlightJsStyleName = "solarized-light" ~}}
|
||||
{{~ include "HtmlShared.html" ~}}
|
||||
+67
-12
@@ -1,8 +1,38 @@
|
||||
/* === GENERAL === */
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discordapp.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discordapp.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discordapp.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discordapp.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Whitney;
|
||||
src: url(https://discordapp.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -27,20 +57,28 @@ img {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.quote {
|
||||
border-left: 4px solid;
|
||||
border-radius: 3px;
|
||||
margin: 8px 0;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.pre {
|
||||
font-family: "Consolas", "Courier New", Courier, Monospace;
|
||||
font-family: "Consolas", "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.pre--multiline {
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
border: 2px solid;
|
||||
border-radius: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.pre--inline {
|
||||
padding: 2px;
|
||||
border-radius: 3px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.mention {
|
||||
@@ -69,7 +107,7 @@ img {
|
||||
.info {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
margin: 0 5px 10px 5px;
|
||||
margin: 0 5px 10px 5px;
|
||||
}
|
||||
|
||||
.info__guild-icon-container {
|
||||
@@ -148,8 +186,15 @@ img {
|
||||
font-size: .75em;
|
||||
}
|
||||
|
||||
.chatlog__message {
|
||||
padding: 2px 5px;
|
||||
margin-right: -5px;
|
||||
margin-left: -5px;
|
||||
background-color: transparent;
|
||||
transition: background-color 1s ease;
|
||||
}
|
||||
|
||||
.chatlog__content {
|
||||
padding-top: 5px;
|
||||
font-size: .9375em;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
@@ -159,20 +204,17 @@ img {
|
||||
font-size: .8em;
|
||||
}
|
||||
|
||||
.chatlog__attachment {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.chatlog__attachment-thumbnail {
|
||||
margin-top: 5px;
|
||||
max-width: 50%;
|
||||
max-height: 500px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__embed {
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
max-width: 520px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chatlog__embed-color-pill {
|
||||
@@ -270,7 +312,7 @@ img {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.chatlog__embed-image {
|
||||
.chatlog__embed-image {
|
||||
max-width: 500px;
|
||||
max-height: 400px;
|
||||
border-radius: 3px;
|
||||
@@ -289,7 +331,7 @@ img {
|
||||
}
|
||||
|
||||
.chatlog__embed-footer-text {
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: .75em;
|
||||
}
|
||||
|
||||
@@ -309,4 +351,17 @@ img {
|
||||
min-width: 9px;
|
||||
margin-left: 6px;
|
||||
font-size: .875em;
|
||||
}
|
||||
|
||||
.chatlog__bot-tag {
|
||||
margin-left: 0.3em;
|
||||
background: #7289da;
|
||||
color: #ffffff;
|
||||
font-size: 0.625em;
|
||||
padding: 1px 2px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
line-height: 1.3;
|
||||
position: relative;
|
||||
top: -.2em;
|
||||
}
|
||||
+48
-18
@@ -9,12 +9,34 @@
|
||||
|
||||
{{~ # Styles ~}}
|
||||
<style>
|
||||
{{ include "HtmlShared.Main.css" }}
|
||||
{{ include "HtmlShared.css" }}
|
||||
</style>
|
||||
<style>
|
||||
{{ ThemeStyleSheet }}
|
||||
</style>
|
||||
|
||||
{{~ # Local scripts ~}}
|
||||
<script>
|
||||
function scrollToMessage(event, id) {
|
||||
var element = document.getElementById('message-' + id);
|
||||
|
||||
if (element !== null && element !== undefined) {
|
||||
event.preventDefault();
|
||||
|
||||
element.classList.add('chatlog__message--highlighted');
|
||||
|
||||
window.scrollTo({
|
||||
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
window.setTimeout(function() {
|
||||
element.classList.remove('chatlog__message--highlighted');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{{~ # Syntax highlighting ~}}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
|
||||
@@ -27,7 +49,7 @@
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
{{~ # Info ~}}
|
||||
<div class="info">
|
||||
<div class="info__guild-icon-container">
|
||||
@@ -36,21 +58,21 @@
|
||||
<div class="info__metadata">
|
||||
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
|
||||
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
|
||||
|
||||
|
||||
{{~ if Model.Channel.Topic ~}}
|
||||
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
|
||||
{{~ end ~}}
|
||||
|
||||
<div class="info__channel-message-count">{{ Model.Messages | array.size | Format "N0" }} messages</div>
|
||||
|
||||
{{~ if Model.From || Model.To ~}}
|
||||
<div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div>
|
||||
|
||||
{{~ if Model.After || Model.Before ~}}
|
||||
<div class="info__channel-date-range">
|
||||
{{~ if Model.From && Model.To ~}}
|
||||
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
|
||||
{{~ else if Model.From ~}}
|
||||
After {{ Model.From | FormatDate | html.escape }}
|
||||
{{~ else if Model.To ~}}
|
||||
Before {{ Model.To | FormatDate | html.escape }}
|
||||
{{~ if Model.After && Model.Before ~}}
|
||||
Between {{ Model.After | FormatDate | html.escape }} and {{ Model.Before | FormatDate | html.escape }}
|
||||
{{~ else if Model.After ~}}
|
||||
After {{ Model.After | FormatDate | html.escape }}
|
||||
{{~ else if Model.Before ~}}
|
||||
Before {{ Model.Before | FormatDate | html.escape }}
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
@@ -64,14 +86,21 @@
|
||||
{{~ # Avatar ~}}
|
||||
<div class="chatlog__author-avatar-container">
|
||||
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="chatlog__messages">
|
||||
{{~ # Author name and timestamp ~}}
|
||||
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}">{{ group.Author.Name | html.escape }}</span>
|
||||
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}" data-user-id="{{ group.Author.Id | html.escape }}">{{ group.Author.Name | html.escape }}</span>
|
||||
|
||||
{{~ # Bot tag ~}}
|
||||
{{~ if group.Author.IsBot ~}}
|
||||
<span class="chatlog__bot-tag">BOT</span>
|
||||
{{~ end ~}}
|
||||
|
||||
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
|
||||
|
||||
{{~ # Messages ~}}
|
||||
{{~ for message in group.Messages ~}}
|
||||
<div class="chatlog__message {{if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
|
||||
{{~ # Content ~}}
|
||||
{{~ if message.Content ~}}
|
||||
<div class="chatlog__content">
|
||||
@@ -166,9 +195,9 @@
|
||||
</a>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{~ # Image ~}}
|
||||
{{~ # Image ~}}
|
||||
{{~ if embed.Image ~}}
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
|
||||
@@ -185,7 +214,7 @@
|
||||
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
|
||||
{{~ end ~}}
|
||||
{{~ end ~}}
|
||||
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
{{~ if embed.Footer ~}}
|
||||
{{~ if embed.Footer.Text ~}}
|
||||
@@ -215,6 +244,7 @@
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,4 +252,4 @@
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
+13
-10
@@ -1,9 +1,8 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using ByteSizeLib;
|
||||
using DiscordChatExporter.Core.Internal;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Services.Internal;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
@@ -17,8 +16,9 @@ namespace DiscordChatExporter.Core.Services
|
||||
var discriminator = json["discriminator"].Value<int>();
|
||||
var name = json["username"].Value<string>();
|
||||
var avatarHash = json["avatar"].Value<string>();
|
||||
var isBot = json["bot"]?.Value<bool>() ?? false;
|
||||
|
||||
return new User(id, discriminator, name, avatarHash);
|
||||
return new User(id, discriminator, name, avatarHash, isBot);
|
||||
}
|
||||
|
||||
private Guild ParseGuild(JToken json)
|
||||
@@ -42,14 +42,14 @@ namespace DiscordChatExporter.Core.Services
|
||||
var guildId = json["guild_id"]?.Value<string>();
|
||||
|
||||
// If the guild ID is blank, it's direct messages
|
||||
if (guildId.IsBlank())
|
||||
if (guildId.IsNullOrWhiteSpace())
|
||||
guildId = Guild.DirectMessages.Id;
|
||||
|
||||
// Try to extract name
|
||||
var name = json["name"]?.Value<string>();
|
||||
|
||||
// If the name is blank, it's direct messages
|
||||
if (name.IsBlank())
|
||||
if (name.IsNullOrWhiteSpace())
|
||||
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
|
||||
|
||||
return new Channel(id, parentId, guildId, name, topic, type);
|
||||
@@ -72,7 +72,7 @@ namespace DiscordChatExporter.Core.Services
|
||||
var fileName = json["filename"].Value<string>();
|
||||
var fileSizeBytes = json["size"].Value<long>();
|
||||
|
||||
var fileSize = ByteSize.FromBytes(fileSizeBytes);
|
||||
var fileSize = new FileSize(fileSizeBytes);
|
||||
|
||||
return new Attachment(id, width, height, url, fileName, fileSize);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ namespace DiscordChatExporter.Core.Services
|
||||
var title = json["title"]?.Value<string>();
|
||||
var description = json["description"]?.Value<string>();
|
||||
var url = json["url"]?.Value<string>();
|
||||
var timestamp = json["timestamp"]?.Value<DateTime>();
|
||||
var timestamp = json["timestamp"]?.Value<DateTime>().ToDateTimeOffset();
|
||||
|
||||
// Get color
|
||||
var color = json["color"] != null
|
||||
@@ -165,8 +165,8 @@ namespace DiscordChatExporter.Core.Services
|
||||
// Get basic data
|
||||
var id = json["id"].Value<string>();
|
||||
var channelId = json["channel_id"].Value<string>();
|
||||
var timestamp = json["timestamp"].Value<DateTime>();
|
||||
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
|
||||
var timestamp = json["timestamp"].Value<DateTime>().ToDateTimeOffset();
|
||||
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>()?.ToDateTimeOffset();
|
||||
var content = json["content"].Value<string>();
|
||||
var type = (MessageType) json["type"].Value<int>();
|
||||
|
||||
@@ -201,8 +201,11 @@ namespace DiscordChatExporter.Core.Services
|
||||
// Get mentioned users
|
||||
var mentionedUsers = json["mentions"].EmptyIfNull().Select(ParseUser).ToArray();
|
||||
|
||||
// Get whether this message is pinned
|
||||
var isPinned = json["pinned"].Value<bool>();
|
||||
|
||||
return new Message(id, channelId, type, author, timestamp, editedTimestamp, content, attachments, embeds,
|
||||
reactions, mentionedUsers);
|
||||
reactions, mentionedUsers, isPinned);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-16
@@ -4,11 +4,11 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using DiscordChatExporter.Core.Internal;
|
||||
using DiscordChatExporter.Core.Services.Exceptions;
|
||||
using DiscordChatExporter.Core.Services.Internal;
|
||||
using Failsafe;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
@@ -40,13 +40,13 @@ namespace DiscordChatExporter.Core.Services
|
||||
: new AuthenticationHeaderValue(token.Value);
|
||||
|
||||
// Add parameters
|
||||
foreach (var parameter in parameters.ExceptBlank())
|
||||
foreach (var parameter in parameters)
|
||||
{
|
||||
var key = parameter.SubstringUntil("=");
|
||||
var value = parameter.SubstringAfter("=");
|
||||
|
||||
// Skip empty values
|
||||
if (value.IsBlank())
|
||||
if (value.IsNullOrWhiteSpace())
|
||||
continue;
|
||||
|
||||
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
||||
@@ -123,24 +123,24 @@ namespace DiscordChatExporter.Core.Services
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
|
||||
{
|
||||
var result = new List<Message>();
|
||||
|
||||
// Get the last message
|
||||
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||
"limit=1", $"before={to?.ToSnowflake()}");
|
||||
"limit=1", $"before={before?.ToSnowflake()}");
|
||||
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
|
||||
|
||||
// If the last message doesn't exist or it's outside of range - return
|
||||
if (lastMessage == null || lastMessage.Timestamp < from)
|
||||
if (lastMessage == null || lastMessage.Timestamp < after)
|
||||
{
|
||||
progress?.Report(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get other messages
|
||||
var offsetId = from?.ToSnowflake() ?? "0";
|
||||
var offsetId = after?.ToSnowflake() ?? "0";
|
||||
while (true)
|
||||
{
|
||||
// Get message batch
|
||||
@@ -215,19 +215,19 @@ namespace DiscordChatExporter.Core.Services
|
||||
}
|
||||
|
||||
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
|
||||
{
|
||||
// Get messages
|
||||
var messages = await GetChannelMessagesAsync(token, channel.Id, from, to, progress);
|
||||
var messages = await GetChannelMessagesAsync(token, channel.Id, after, before, progress);
|
||||
|
||||
// Get mentionables
|
||||
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
|
||||
|
||||
return new ChatLog(guild, channel, from, to, messages, mentionables);
|
||||
return new ChatLog(guild, channel, after, before, messages, mentionables);
|
||||
}
|
||||
|
||||
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Channel channel,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
|
||||
{
|
||||
// Get guild
|
||||
var guild = channel.GuildId == Guild.DirectMessages.Id
|
||||
@@ -235,17 +235,17 @@ namespace DiscordChatExporter.Core.Services
|
||||
: await GetGuildAsync(token, channel.GuildId);
|
||||
|
||||
// Get the chat log
|
||||
return await GetChatLogAsync(token, guild, channel, from, to, progress);
|
||||
return await GetChatLogAsync(token, guild, channel, after, before, progress);
|
||||
}
|
||||
|
||||
public async Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
|
||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null)
|
||||
DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress<double> progress = null)
|
||||
{
|
||||
// Get channel
|
||||
var channel = await GetChannelAsync(token, channelId);
|
||||
|
||||
// Get the chat log
|
||||
return await GetChatLogAsync(token, channel, from, to, progress);
|
||||
return await GetChatLogAsync(token, channel, after, before, progress);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Failsafe" Version="1.1.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
|
||||
<PackageReference Include="Onova" Version="2.4.5" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.2" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core.Rendering\DiscordChatExporter.Core.Rendering.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exceptions
|
||||
namespace DiscordChatExporter.Core.Services.Exceptions
|
||||
{
|
||||
public class HttpErrorStatusCodeException : Exception
|
||||
{
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using DiscordChatExporter.Core.Rendering;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services
|
||||
{
|
||||
public class ExportService
|
||||
{
|
||||
private readonly SettingsService _settingsService;
|
||||
|
||||
public ExportService(SettingsService settingsService)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
}
|
||||
|
||||
private IChatLogRenderer CreateRenderer(ChatLog chatLog, ExportFormat format)
|
||||
{
|
||||
if (format == ExportFormat.PlainText)
|
||||
return new PlainTextChatLogRenderer(chatLog, _settingsService.DateFormat);
|
||||
|
||||
if (format == ExportFormat.HtmlDark)
|
||||
return new HtmlChatLogRenderer(chatLog, "Dark", _settingsService.DateFormat);
|
||||
|
||||
if (format == ExportFormat.HtmlLight)
|
||||
return new HtmlChatLogRenderer(chatLog, "Light", _settingsService.DateFormat);
|
||||
|
||||
if (format == ExportFormat.Csv)
|
||||
return new CsvChatLogRenderer(chatLog, _settingsService.DateFormat);
|
||||
|
||||
throw new ArgumentOutOfRangeException(nameof(format), $"Unknown format [{format}].");
|
||||
}
|
||||
|
||||
private async Task ExportChatLogAsync(ChatLog chatLog, string filePath, ExportFormat format)
|
||||
{
|
||||
// Create output directory
|
||||
var dirPath = Path.GetDirectoryName(filePath);
|
||||
if (!dirPath.IsNullOrWhiteSpace())
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
// Render chat log to output file
|
||||
using (var writer = File.CreateText(filePath))
|
||||
await CreateRenderer(chatLog, format).RenderAsync(writer);
|
||||
}
|
||||
|
||||
public async Task ExportChatLogAsync(ChatLog chatLog, string filePath, ExportFormat format, int? partitionLimit)
|
||||
{
|
||||
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
|
||||
if (partitionLimit == null || partitionLimit <= 0 || chatLog.Messages.Count <= partitionLimit)
|
||||
{
|
||||
await ExportChatLogAsync(chatLog, filePath, format);
|
||||
}
|
||||
// Otherwise split into partitions and export separately
|
||||
else
|
||||
{
|
||||
// Create partitions by grouping up to X contiguous messages into separate chat logs
|
||||
var partitions = chatLog.Messages.GroupContiguous(g => g.Count < partitionLimit.Value)
|
||||
.Select(g => new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.After, chatLog.Before, g, chatLog.Mentionables))
|
||||
.ToArray();
|
||||
|
||||
// Split file path into components
|
||||
var dirPath = Path.GetDirectoryName(filePath);
|
||||
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
|
||||
var fileExt = Path.GetExtension(filePath);
|
||||
|
||||
// Export each partition separately
|
||||
var partitionNumber = 1;
|
||||
foreach (var partition in partitions)
|
||||
{
|
||||
// Compose new file name
|
||||
var partitionFilePath = $"{fileNameWithoutExt} [{partitionNumber} of {partitions.Length}]{fileExt}";
|
||||
|
||||
// Compose full file path
|
||||
if (!dirPath.IsNullOrWhiteSpace())
|
||||
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
|
||||
|
||||
// Export
|
||||
await ExportChatLogAsync(partition, partitionFilePath, format);
|
||||
|
||||
// Increment partition number
|
||||
partitionNumber++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -5,17 +5,17 @@ using System.Text;
|
||||
using DiscordChatExporter.Core.Models;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Helpers
|
||||
namespace DiscordChatExporter.Core.Services.Helpers
|
||||
{
|
||||
public static class ExportHelper
|
||||
{
|
||||
public static bool IsDirectoryPath(string path)
|
||||
=> path.Last() == Path.DirectorySeparatorChar ||
|
||||
path.Last() == Path.AltDirectorySeparatorChar ||
|
||||
Path.GetExtension(path).IsBlank();
|
||||
public static bool IsDirectoryPath(string path) =>
|
||||
path.Last() == Path.DirectorySeparatorChar ||
|
||||
path.Last() == Path.AltDirectorySeparatorChar ||
|
||||
(Path.GetExtension(path).IsNullOrWhiteSpace() && !File.Exists(path));
|
||||
|
||||
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
|
||||
DateTime? from = null, DateTime? to = null)
|
||||
DateTimeOffset? after = null, DateTimeOffset? before = null)
|
||||
{
|
||||
var result = new StringBuilder();
|
||||
|
||||
@@ -23,24 +23,24 @@ namespace DiscordChatExporter.Core.Helpers
|
||||
result.Append($"{guild.Name} - {channel.Name} [{channel.Id}]");
|
||||
|
||||
// Append date range
|
||||
if (from != null || to != null)
|
||||
if (after != null || before != null)
|
||||
{
|
||||
result.Append(" (");
|
||||
|
||||
// Both 'from' and 'to' are set
|
||||
if (from != null && to != null)
|
||||
// Both 'after' and 'before' are set
|
||||
if (after != null && before != null)
|
||||
{
|
||||
result.Append($"{from:yyyy-MM-dd} to {to:yyyy-MM-dd}");
|
||||
result.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
|
||||
}
|
||||
// Only 'from' is set
|
||||
else if (from != null)
|
||||
// Only 'after' is set
|
||||
else if (after != null)
|
||||
{
|
||||
result.Append($"after {from:yyyy-MM-dd}");
|
||||
result.Append($"after {after:yyyy-MM-dd}");
|
||||
}
|
||||
// Only 'to' is set
|
||||
// Only 'before' is set
|
||||
else
|
||||
{
|
||||
result.Append($"before {to:yyyy-MM-dd}");
|
||||
result.Append($"before {before:yyyy-MM-dd}");
|
||||
}
|
||||
|
||||
result.Append(")");
|
||||
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
|
||||
namespace DiscordChatExporter.Core.Services.Internal
|
||||
{
|
||||
internal static class Extensions
|
||||
{
|
||||
public static DateTimeOffset ToDateTimeOffset(this DateTime dateTime) => new DateTimeOffset(dateTime);
|
||||
|
||||
public static string ToSnowflake(this DateTimeOffset dateTime)
|
||||
{
|
||||
var value = ((ulong) dateTime.ToUnixTimeMilliseconds() - 1420070400000UL) << 22;
|
||||
return value.ToString();
|
||||
}
|
||||
|
||||
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net461</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\PlainText\Template.txt" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Template.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Template.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.html" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Theme.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Theme.css" />
|
||||
<EmbeddedResource Include="Resources\ExportTemplates\Csv\Template.csv" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="mscorlib" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ByteSize" Version="1.3.0" />
|
||||
<PackageReference Include="Failsafe" Version="1.1.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
|
||||
<PackageReference Include="Onova" Version="2.2.0" />
|
||||
<PackageReference Include="Scriban" Version="1.2.9" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user