mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 22:54:38 +02:00
Compare commits
91 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7951703cf2 | |||
| 795dfbb213 | |||
| 6d9bc3625f | |||
| 4bfb2ec7fd | |||
| 359278afec | |||
| 19f58f2c8c | |||
| 640b1f5866 | |||
| d13d04e75b | |||
| 6bf0ce3790 | |||
| aef8a1f6d2 | |||
| a63bf95212 | |||
| af9855fc15 | |||
| 4113101c0c | |||
| cac48b3541 | |||
| 0a60dea5c1 | |||
| 889446fff7 | |||
| 6d1cfef729 | |||
| 77bf32aa5a | |||
| ac2b450892 | |||
| 4046fd0ad9 | |||
| 6fa61bf75a | |||
| 1976e3ad08 | |||
| adbe544c57 | |||
| 6f4d1c3d77 | |||
| 33f71c1612 | |||
| 9b78472e05 | |||
| 1f6d1f21e6 | |||
| 918cb4820f | |||
| 32af1aa05d | |||
| dda32d8af2 | |||
| d1d82f975b | |||
| b1de9c4eca | |||
| 48ec4d085a | |||
| 668ac70fae | |||
| 0d1ce38489 | |||
| 666f2d8d4a | |||
| 2dd0742c1c | |||
| 69fe8af87a | |||
| e493357a9f | |||
| 492003c75a | |||
| 28175e2110 | |||
| a564906719 | |||
| 019278f6d4 | |||
| 5161593e0f | |||
| 847a05a269 | |||
| f09f30c7bd | |||
| 88727a1fe6 | |||
| bb98049df7 | |||
| e519bdcc6f | |||
| 1356d4483a | |||
| 4a3dc5c5ee | |||
| e665356890 | |||
| 7b614ed448 | |||
| 6531462220 | |||
| e89f2735a0 | |||
| 01d276a9dc | |||
| c446d4069a | |||
| ba5718aaf5 | |||
| 20ff4a15eb | |||
| 991dfccf3c | |||
| 524c86b8f2 | |||
| e4b0d60c40 | |||
| 65c5df89f4 | |||
| 659c499bd0 | |||
| b6fcbac030 | |||
| b3e2dd3994 | |||
| ba5790e312 | |||
| 2859a740eb | |||
| b88a1f8b47 | |||
| 78b44aed19 | |||
| 13925c69af | |||
| e22e1c03fb | |||
| bf5cedb646 | |||
| 17b0161ade | |||
| fa1e84b518 | |||
| 6d5ed9e9ca | |||
| bdaeea5512 | |||
| dddb13fcc5 | |||
| cacbb58683 | |||
| ca727e9f23 | |||
| fd15b62adf | |||
| 78d01b47b4 | |||
| daf38153f4 | |||
| ac91c536dd | |||
| 0dbd619244 | |||
| 0d3510222e | |||
| 083bdef419 | |||
| d620010e85 | |||
| beadc25e61 | |||
| 8bc29c4928 | |||
| 0b14952346 |
@@ -1,3 +1,57 @@
|
|||||||
|
### 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.
|
||||||
|
- Added retry policy for all 5xx status codes to prevent random crashes.
|
||||||
|
|
||||||
|
### v2.10.2 (10-Mar-2019)
|
||||||
|
|
||||||
|
- [HTML] Updated message grouping algorithm to make it the same as in Discord. Removed "message group limit" setting and parameter.
|
||||||
|
- [HTML] Added small margin at the end of the chatlog so it doesn't look like it was truncated.
|
||||||
|
- [CLI] Fixed an issue where the app would crash if stdout was redirected. Progress will not be reported in such cases.
|
||||||
|
|
||||||
|
### v2.10.1 (06-Mar-2019)
|
||||||
|
|
||||||
|
- [HTML] Fixed an issue where multiple emojis on a single line would get rendered as one emoji.
|
||||||
|
- [HTML] Fixed an issue where an open square bracket character would incorrectly render as an emoji.
|
||||||
|
- [HTML] Added alt text to emoji images so that they will be rendered using default font when Twemoji images are not available.
|
||||||
|
|
||||||
|
### v2.10 (03-Mar-2019)
|
||||||
|
|
||||||
|
- [HTML] Reworked the entire markdown parsing engine which fixes numerous rendering inconsistencies (over 15 issues fixed).
|
||||||
|
- [HTML] Added support for animated emojis.
|
||||||
|
- [HTML] Standard emojis are now rendered using Twemoji, the same image set that Discord uses.
|
||||||
|
- [HTML] Fixed an issue where video attachments would render incorrectly.
|
||||||
|
- [HTML] Minor fixes in styling.
|
||||||
|
- [GUI] Aggregated export progress is now shown in the taskbar.
|
||||||
|
- [GUI] Checkmarks are now displayed next to channels that are selected.
|
||||||
|
- [CLI] Added progress reporting when exporting chat logs.
|
||||||
|
|
||||||
|
### v2.9.1 (10-Feb-2019)
|
||||||
|
|
||||||
|
- Fixed an issue where some of the last messages in a channel would sometimes not get exported.
|
||||||
|
|
||||||
|
### v2.9 (09-Feb-2019)
|
||||||
|
|
||||||
|
- Added categories to channel list in GUI. Channels are now also grouped by category.
|
||||||
|
- Added ability to export multiple channels at once in GUI by selecting them with shift or ctrl keys.
|
||||||
|
- Added CLI command to export all available channels in a guild (`exportguild`).
|
||||||
|
- Added CLI command to export all direct message channels (`exportdm`).
|
||||||
|
- Added retry policy for 503 status code errors to prevent random crashes.
|
||||||
|
- Changed how channels are exported in GUI. Now you need to select them first and then click a button to export. You can also double click a single channel to export it as before.
|
||||||
|
- Improved default file name generation by adding date ranges and channel ID to the file name.
|
||||||
|
- Improved naming of files exported with partitioning.
|
||||||
|
- Improved usage instructions.
|
||||||
|
- Improved GUI layout and styling.
|
||||||
|
- Fixed an issue where dialogs would sometimes appear over other windows.
|
||||||
|
- Fixed an issue where launching DiscordChatExporter from Windows Start Menu would make it crash.
|
||||||
|
|
||||||
### v2.8 (03-Nov-2018)
|
### v2.8 (03-Nov-2018)
|
||||||
|
|
||||||
- Added support for partitioning which lets you split the output into multiple files by setting the partition message limit (`-p` parameter for CLI).
|
- Added support for partitioning which lets you split the output into multiple files by setting the partition message limit (`-p` parameter for CLI).
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||||
|
<metadata>
|
||||||
|
<id>discordchatexporter</id>
|
||||||
|
<title>DiscordChatExporter</title>
|
||||||
|
<version>2.12</version>
|
||||||
|
<owners>Tyrrrz</owners>
|
||||||
|
<authors>Tyrrrz</authors>
|
||||||
|
<copyright>Copyright (C) Alexey Golub</copyright>
|
||||||
|
<summary>Exports Discord chat logs to a file</summary>
|
||||||
|
<description>DiscordChatExporter can be used to export message history from a Discord channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. The tool also lets you select from/to dates to limit the exported messages. There are options to configure the output, such as date format, color theme, message grouping limit, etc.</description>
|
||||||
|
<tags>discordchatexporter discord export chat logs archive gui</tags>
|
||||||
|
<projectSourceUrl>https://github.com/Tyrrrz/DiscordChatExporter</projectSourceUrl>
|
||||||
|
<packageSourceUrl>https://github.com/Tyrrrz/DiscordChatExporter</packageSourceUrl>
|
||||||
|
<projectUrl>http://tyrrrz.me/Projects/DiscordChatExporter</projectUrl>
|
||||||
|
<releaseNotes>https://github.com/Tyrrrz/DiscordChatExporter/blob/master/Changelog.md</releaseNotes>
|
||||||
|
<iconUrl>https://raw.githubusercontent.com/Tyrrrz/DiscordChatExporter/master/favicon.png</iconUrl>
|
||||||
|
<licenseUrl>https://raw.githubusercontent.com/Tyrrrz/DiscordChatExporter/master/License.txt</licenseUrl>
|
||||||
|
<requireLicenseAcceptance>true</requireLicenseAcceptance>
|
||||||
|
<bugTrackerUrl>https://github.com/Tyrrrz/DiscordChatExporter/issues</bugTrackerUrl>
|
||||||
|
</metadata>
|
||||||
|
<files>
|
||||||
|
<file src="tools\**" target="tools" />
|
||||||
|
</files>
|
||||||
|
</package>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$packageName = $env:ChocolateyPackageName
|
||||||
|
$installDirPath = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
|
||||||
|
|
||||||
|
# Install package (GUI)
|
||||||
|
|
||||||
|
$packageArgs = @{
|
||||||
|
packageName = $packageName
|
||||||
|
unzipLocation = $installDirPath
|
||||||
|
url = 'https://github.com/Tyrrrz/DiscordChatExporter/releases/download/2.12/DiscordChatExporter.zip'
|
||||||
|
}
|
||||||
|
Install-ChocolateyZipPackage @packageArgs
|
||||||
|
|
||||||
|
# Mark the executable as GUI
|
||||||
|
New-Item (Join-Path $installDirPath "DiscordChatExporter.exe.gui") -ItemType File -Force
|
||||||
|
|
||||||
|
# Install package (CLI)
|
||||||
|
|
||||||
|
$packageArgs = @{
|
||||||
|
packageName = $packageName
|
||||||
|
unzipLocation = $installDirPath
|
||||||
|
url = 'https://github.com/Tyrrrz/DiscordChatExporter/releases/download/2.12/DiscordChatExporter.CLI.zip'
|
||||||
|
}
|
||||||
|
Install-ChocolateyZipPackage @packageArgs
|
||||||
+22
-6
@@ -1,11 +1,27 @@
|
|||||||
New-Item "$PSScriptRoot\bin" -ItemType Directory -Force
|
# --- PORTABLE / GUI ---
|
||||||
|
|
||||||
# GUI
|
# Get files
|
||||||
$files = @()
|
$files = @()
|
||||||
|
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
|
||||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Gui\bin\Release\*" -Include "*.exe", "*.dll", "*.config"
|
$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
|
||||||
|
New-Item "$PSScriptRoot\Portable\GUI\bin" -ItemType Directory -Force
|
||||||
|
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\GUI\bin\DiscordChatExporter.zip" -Force
|
||||||
|
|
||||||
|
# --- PORTABLE / CLI ---
|
||||||
|
|
||||||
|
# Get files
|
||||||
$files = @()
|
$files = @()
|
||||||
$files += Get-ChildItem -Path "$PSScriptRoot\..\DiscordChatExporter.Cli\bin\Release\net461\*" -Include "*.exe", "*.dll", "*.config"
|
$files += Get-Item -Path "$PSScriptRoot\..\License.txt"
|
||||||
$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
|
||||||
|
New-Item "$PSScriptRoot\Portable\CLI\bin" -ItemType Directory -Force
|
||||||
|
$files | Compress-Archive -DestinationPath "$PSScriptRoot\Portable\CLI\bin\DiscordChatExporter.CLI.zip" -Force
|
||||||
|
|
||||||
|
# --- CHOCOLATEY ---
|
||||||
|
|
||||||
|
# Create package
|
||||||
|
New-Item "$PSScriptRoot\Choco\bin\" -ItemType Directory -Force
|
||||||
|
choco pack $PSScriptRoot\Choco\discordchatexporter.nuspec --out $PSScriptRoot\Choco\bin\
|
||||||
@@ -1,26 +1,24 @@
|
|||||||
using CommonServiceLocator;
|
using DiscordChatExporter.Core.Services;
|
||||||
using DiscordChatExporter.Core.Services;
|
using StyletIoC;
|
||||||
using GalaSoft.MvvmLight.Ioc;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli
|
namespace DiscordChatExporter.Cli
|
||||||
{
|
{
|
||||||
public class Container
|
public static class Container
|
||||||
{
|
{
|
||||||
public Container()
|
public static IContainer Instance { get; }
|
||||||
{
|
|
||||||
ServiceLocator.SetLocatorProvider(() => SimpleIoc.Default);
|
|
||||||
SimpleIoc.Default.Reset();
|
|
||||||
|
|
||||||
// Services
|
static Container()
|
||||||
SimpleIoc.Default.Register<IDataService, DataService>();
|
|
||||||
SimpleIoc.Default.Register<IExportService, ExportService>();
|
|
||||||
SimpleIoc.Default.Register<ISettingsService, SettingsService>();
|
|
||||||
SimpleIoc.Default.Register<IUpdateService, UpdateService>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public T Resolve<T>(string key = null)
|
|
||||||
{
|
{
|
||||||
return ServiceLocator.Current.GetInstance<T>(key);
|
var builder = new StyletIoCBuilder();
|
||||||
|
|
||||||
|
// Autobind the .Services assembly
|
||||||
|
builder.Autobind(typeof(DataService).Assembly);
|
||||||
|
|
||||||
|
// Bind settings as singleton
|
||||||
|
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
|
||||||
|
|
||||||
|
// Set instance
|
||||||
|
Instance = builder.BuildContainer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,24 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFramework>net461</TargetFramework>
|
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
|
||||||
<Version>2.8</Version>
|
<Version>2.12</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) 2017-2018 Alexey Golub</Copyright>
|
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" Version="2.2.1" />
|
<PackageReference Include="CommandLineParser" Version="2.3.0" />
|
||||||
<PackageReference Include="CommonServiceLocator" Version="2.0.3" />
|
<PackageReference Include="Stylet" Version="1.1.22" />
|
||||||
<PackageReference Include="MvvmLightLibs" Version="5.4.1" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
</ItemGroup>
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
||||||
</ItemGroup>
|
<ProjectReference Include="..\DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Internal
|
||||||
|
{
|
||||||
|
internal class InlineProgress : IProgress<double>, IDisposable
|
||||||
|
{
|
||||||
|
private readonly int _posX;
|
||||||
|
private readonly int _posY;
|
||||||
|
|
||||||
|
private bool _isCompleted;
|
||||||
|
|
||||||
|
public InlineProgress()
|
||||||
|
{
|
||||||
|
// If output is not redirected - save initial cursor position
|
||||||
|
if (!Console.IsOutputRedirected)
|
||||||
|
{
|
||||||
|
_posX = Console.CursorLeft;
|
||||||
|
_posY = Console.CursorTop;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Report(double progress)
|
||||||
|
{
|
||||||
|
// If output is not redirected - reset cursor position and write progress
|
||||||
|
if (!Console.IsOutputRedirected)
|
||||||
|
{
|
||||||
|
Console.SetCursorPosition(_posX, _posY);
|
||||||
|
Console.WriteLine($"{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);
|
||||||
|
|
||||||
|
// Inform about completion
|
||||||
|
if (_isCompleted)
|
||||||
|
Console.WriteLine("Completed ✓");
|
||||||
|
else
|
||||||
|
Console.WriteLine("Failed X");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,28 +11,25 @@ namespace DiscordChatExporter.Cli
|
|||||||
private static void PrintTokenHelp()
|
private static void PrintTokenHelp()
|
||||||
{
|
{
|
||||||
Console.WriteLine("# To get user token:");
|
Console.WriteLine("# To get user token:");
|
||||||
Console.WriteLine(" 1. Open Discord app");
|
Console.WriteLine(" 1. Open Discord");
|
||||||
Console.WriteLine(" 2. Log in if you haven't");
|
Console.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||||
Console.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
Console.WriteLine(" 3. Press Ctrl+R to reload");
|
||||||
Console.WriteLine(" 4. Press Ctrl+R to trigger reload");
|
Console.WriteLine(" 4. Navigate to the Application tab");
|
||||||
Console.WriteLine(" 5. Navigate to the Application tab");
|
Console.WriteLine(" 5. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
||||||
Console.WriteLine(" 6. Select \"Local Storage\" > \"https://discordapp.com\" on the left");
|
Console.WriteLine(" 6. Find \"token\" under key and copy the value");
|
||||||
Console.WriteLine(" 7. Find \"token\" under key and copy the value");
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("# To get bot token:");
|
Console.WriteLine("# To get bot token:");
|
||||||
Console.WriteLine(" 1. Go to Discord developer portal");
|
Console.WriteLine(" 1. Go to Discord developer portal");
|
||||||
Console.WriteLine(" 2. Log in if you haven't");
|
Console.WriteLine(" 2. Open your application's settings");
|
||||||
Console.WriteLine(" 3. Open your application's settings");
|
Console.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||||
Console.WriteLine(" 4. Navigate to the Bot section on the left");
|
Console.WriteLine(" 4. Under Token click Copy");
|
||||||
Console.WriteLine(" 5. Under Token click Copy");
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
Console.WriteLine("# To get guild or channel ID:");
|
Console.WriteLine("# To get guild or channel ID:");
|
||||||
Console.WriteLine(" 1. Open Discord app");
|
Console.WriteLine(" 1. Open Discord");
|
||||||
Console.WriteLine(" 2. Log in if you haven't");
|
Console.WriteLine(" 2. Open Settings");
|
||||||
Console.WriteLine(" 3. Open Settings");
|
Console.WriteLine(" 3. Go to Appearance section");
|
||||||
Console.WriteLine(" 4. Go to Appearance section");
|
Console.WriteLine(" 4. Enable Developer Mode");
|
||||||
Console.WriteLine(" 5. Enable Developer Mode");
|
Console.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID");
|
||||||
Console.WriteLine(" 6. Right click on the desired guild or channel and click Copy ID");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
@@ -40,7 +37,9 @@ namespace DiscordChatExporter.Cli
|
|||||||
// Get all verb types
|
// Get all verb types
|
||||||
var verbTypes = new[]
|
var verbTypes = new[]
|
||||||
{
|
{
|
||||||
typeof(ExportChatOptions),
|
typeof(ExportChannelOptions),
|
||||||
|
typeof(ExportDirectMessagesOptions),
|
||||||
|
typeof(ExportGuildOptions),
|
||||||
typeof(GetChannelsOptions),
|
typeof(GetChannelsOptions),
|
||||||
typeof(GetDirectMessageChannelsOptions),
|
typeof(GetDirectMessageChannelsOptions),
|
||||||
typeof(GetGuildsOptions)
|
typeof(GetGuildsOptions)
|
||||||
@@ -50,7 +49,9 @@ namespace DiscordChatExporter.Cli
|
|||||||
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
|
var parsedArgs = Parser.Default.ParseArguments(args, verbTypes);
|
||||||
|
|
||||||
// Execute commands
|
// Execute commands
|
||||||
parsedArgs.WithParsed<ExportChatOptions>(o => new ExportChatVerb(o).Execute());
|
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<GetChannelsOptions>(o => new GetChannelsVerb(o).Execute());
|
||||||
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
|
parsedArgs.WithParsed<GetDirectMessageChannelsOptions>(o => new GetDirectMessageChannelsVerb(o).Execute());
|
||||||
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
|
parsedArgs.WithParsed<GetGuildsOptions>(o => new GetGuildsVerb(o).Execute());
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Internal;
|
||||||
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
|
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.EmptyIfNull().IsWhiteSpace())
|
||||||
|
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.EmptyIfNull().IsWhiteSpace() || 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
|
||||||
|
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
|
||||||
|
|
||||||
|
// Report successful completion
|
||||||
|
progress.ReportCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DiscordChatExporter.Cli.Verbs.Options;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
using DiscordChatExporter.Core.Services;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Verbs
|
|
||||||
{
|
|
||||||
public class ExportChatVerb : Verb<ExportChatOptions>
|
|
||||||
{
|
|
||||||
public ExportChatVerb(ExportChatOptions options)
|
|
||||||
: base(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task ExecuteAsync()
|
|
||||||
{
|
|
||||||
// Get services
|
|
||||||
var container = new Container();
|
|
||||||
var settingsService = container.Resolve<ISettingsService>();
|
|
||||||
var dataService = container.Resolve<IDataService>();
|
|
||||||
var exportService = container.Resolve<IExportService>();
|
|
||||||
|
|
||||||
// Configure settings
|
|
||||||
if (Options.DateFormat.IsNotBlank())
|
|
||||||
settingsService.DateFormat = Options.DateFormat;
|
|
||||||
if (Options.MessageGroupLimit > 0)
|
|
||||||
settingsService.MessageGroupLimit = Options.MessageGroupLimit;
|
|
||||||
|
|
||||||
// Get chat log
|
|
||||||
var chatLog = await dataService.GetChatLogAsync(Options.GetToken(), Options.ChannelId,
|
|
||||||
Options.After, Options.Before);
|
|
||||||
|
|
||||||
// Generate file path if not set
|
|
||||||
var filePath = Options.FilePath;
|
|
||||||
if (filePath == null || filePath.EndsWith("/") || filePath.EndsWith("\\"))
|
|
||||||
{
|
|
||||||
filePath += $"{chatLog.Guild.Name} - {chatLog.Channel.Name}.{Options.ExportFormat.GetFileExtension()}"
|
|
||||||
.Replace(Path.GetInvalidFileNameChars(), '_');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export
|
|
||||||
exportService.ExportChatLog(chatLog, filePath, Options.ExportFormat, Options.PartitionLimit);
|
|
||||||
|
|
||||||
// Print result
|
|
||||||
Console.WriteLine($"Exported chat to [{filePath}]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
|
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.EmptyIfNull().IsWhiteSpace())
|
||||||
|
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
|
||||||
|
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
|
||||||
|
Options.PartitionLimit);
|
||||||
|
|
||||||
|
// Report successful completion
|
||||||
|
progress.ReportCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,79 @@
|
|||||||
|
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.Models;
|
||||||
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
|
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.EmptyIfNull().IsWhiteSpace())
|
||||||
|
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
|
||||||
|
await exportService.ExportChatLogAsync(chatLog, filePath, Options.ExportFormat,
|
||||||
|
Options.PartitionLimit);
|
||||||
|
|
||||||
|
// Report successful completion
|
||||||
|
progress.ReportCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ using System.Threading.Tasks;
|
|||||||
using DiscordChatExporter.Cli.Verbs.Options;
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Core.Services;
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Verbs
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
{
|
{
|
||||||
@@ -18,15 +17,16 @@ namespace DiscordChatExporter.Cli.Verbs
|
|||||||
public override async Task ExecuteAsync()
|
public override async Task ExecuteAsync()
|
||||||
{
|
{
|
||||||
// Get data service
|
// Get data service
|
||||||
var container = new Container();
|
var dataService = Container.Instance.Get<DataService>();
|
||||||
var dataService = container.Resolve<IDataService>();
|
|
||||||
|
|
||||||
// Get channels
|
// Get channels
|
||||||
var channels = await dataService.GetGuildChannelsAsync(Options.GetToken(), Options.GuildId);
|
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
|
// Print result
|
||||||
foreach (var channel in channels.Where(c => c.Type.IsEither(ChannelType.GuildTextChat))
|
foreach (var channel in channels)
|
||||||
.OrderBy(c => c.Name))
|
|
||||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ namespace DiscordChatExporter.Cli.Verbs
|
|||||||
public override async Task ExecuteAsync()
|
public override async Task ExecuteAsync()
|
||||||
{
|
{
|
||||||
// Get data service
|
// Get data service
|
||||||
var container = new Container();
|
var dataService = Container.Instance.Get<DataService>();
|
||||||
var dataService = container.Resolve<IDataService>();
|
|
||||||
|
|
||||||
// Get channels
|
// Get channels
|
||||||
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
|
var channels = await dataService.GetDirectMessageChannelsAsync(Options.GetToken());
|
||||||
|
|
||||||
|
// Order channels
|
||||||
|
channels = channels.OrderBy(c => c.Name).ToArray();
|
||||||
|
|
||||||
// Print result
|
// Print result
|
||||||
foreach (var channel in channels.OrderBy(c => c.Name))
|
foreach (var channel in channels)
|
||||||
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
Console.WriteLine($"{channel.Id} | {channel.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ namespace DiscordChatExporter.Cli.Verbs
|
|||||||
public override async Task ExecuteAsync()
|
public override async Task ExecuteAsync()
|
||||||
{
|
{
|
||||||
// Get data service
|
// Get data service
|
||||||
var container = new Container();
|
var dataService = Container.Instance.Get<DataService>();
|
||||||
var dataService = container.Resolve<IDataService>();
|
|
||||||
|
|
||||||
// Get guilds
|
// Get guilds
|
||||||
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
|
var guilds = await dataService.GetUserGuildsAsync(Options.GetToken());
|
||||||
|
|
||||||
|
// Order guilds
|
||||||
|
guilds = guilds.OrderBy(g => g.Name).ToArray();
|
||||||
|
|
||||||
// Print result
|
// Print result
|
||||||
foreach (var guild in guilds.OrderBy(g => g.Name))
|
foreach (var guild in guilds)
|
||||||
Console.WriteLine($"{guild.Id} | {guild.Name}");
|
Console.WriteLine($"{guild.Id} | {guild.Name}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using CommandLine;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
|
{
|
||||||
|
[Verb("exportdm", HelpText = "Export all direct message channels.")]
|
||||||
|
public class ExportDirectMessagesOptions : ExportOptions
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-10
@@ -4,21 +4,19 @@ using DiscordChatExporter.Core.Models;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
{
|
{
|
||||||
[Verb("export", HelpText = "Export channel chat log to a file.")]
|
public abstract class ExportOptions : TokenOptions
|
||||||
public class ExportChatOptions : TokenOptions
|
|
||||||
{
|
{
|
||||||
[Option('c', "channel", Required = true, HelpText = "Channel ID.")]
|
|
||||||
public string ChannelId { get; set; }
|
|
||||||
|
|
||||||
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
|
[Option('f', "format", Default = ExportFormat.HtmlDark, HelpText = "Output file format.")]
|
||||||
public ExportFormat ExportFormat { get; set; }
|
public ExportFormat ExportFormat { get; set; }
|
||||||
|
|
||||||
[Option('o', "output", Default = null, HelpText = "Output file path.")]
|
[Option('o', "output", Default = null, HelpText = "Output file or directory path.")]
|
||||||
public string FilePath { get; set; }
|
public string OutputPath { get; set; }
|
||||||
|
|
||||||
|
// HACK: CommandLineParser doesn't support DateTimeOffset
|
||||||
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
|
[Option("after", Default = null, HelpText = "Limit to messages sent after this date.")]
|
||||||
public DateTime? After { get; set; }
|
public DateTime? After { get; set; }
|
||||||
|
|
||||||
|
// HACK: CommandLineParser doesn't support DateTimeOffset
|
||||||
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
|
[Option("before", Default = null, HelpText = "Limit to messages sent before this date.")]
|
||||||
public DateTime? Before { get; set; }
|
public DateTime? Before { get; set; }
|
||||||
|
|
||||||
@@ -27,8 +25,5 @@ namespace DiscordChatExporter.Cli.Verbs.Options
|
|||||||
|
|
||||||
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
|
[Option("dateformat", Default = null, HelpText = "Date format used in output.")]
|
||||||
public string DateFormat { get; set; }
|
public string DateFormat { get; set; }
|
||||||
|
|
||||||
[Option("grouplimit", Default = 0, HelpText = "Message group limit.")]
|
|
||||||
public int MessageGroupLimit { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ using DiscordChatExporter.Core.Models;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Verbs.Options
|
namespace DiscordChatExporter.Cli.Verbs.Options
|
||||||
{
|
{
|
||||||
public class TokenOptions
|
public abstract class TokenOptions
|
||||||
{
|
{
|
||||||
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
|
[Option('t', "token", Required = true, HelpText = "Authorization token.")]
|
||||||
public string TokenValue { get; set; }
|
public string TokenValue { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</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(string input, int startIndex, int length)
|
||||||
|
{
|
||||||
|
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(input, startIndex, length);
|
||||||
|
|
||||||
|
// If there's no match - continue
|
||||||
|
if (match == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// If this match is earlier than previous earliest - replace
|
||||||
|
if (earliestMatch == null || match.StartIndex < earliestMatch.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.StartIndex == startIndex)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return earliestMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
|
{
|
||||||
|
internal static class Extensions
|
||||||
|
{
|
||||||
|
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
|
||||||
|
int startIndex, int length, Func<string, T> fallbackTransform)
|
||||||
|
{
|
||||||
|
// Get end index for simplicity
|
||||||
|
var endIndex = startIndex + length;
|
||||||
|
|
||||||
|
// Loop through segments divided by individual matches
|
||||||
|
var currentIndex = startIndex;
|
||||||
|
while (currentIndex < endIndex)
|
||||||
|
{
|
||||||
|
// Find a match within this segment
|
||||||
|
var match = matcher.Match(input, currentIndex, 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.StartIndex > currentIndex)
|
||||||
|
{
|
||||||
|
var fallback = input.Substring(currentIndex, match.StartIndex - currentIndex);
|
||||||
|
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield match
|
||||||
|
yield return match;
|
||||||
|
|
||||||
|
// Shift current index to the end of the match
|
||||||
|
currentIndex = match.StartIndex + match.Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If EOL wasn't reached - transform and yield remaining part as fallback
|
||||||
|
if (currentIndex < endIndex)
|
||||||
|
{
|
||||||
|
var fallback = input.Substring(currentIndex);
|
||||||
|
yield return new ParsedMatch<T>(currentIndex, fallback.Length, fallbackTransform(fallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher, string input,
|
||||||
|
Func<string, T> fallbackTransform) => matcher.MatchAll(input, 0, input.Length, fallbackTransform);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
|
{
|
||||||
|
internal interface IMatcher<T>
|
||||||
|
{
|
||||||
|
ParsedMatch<T> Match(string input, int startIndex, int length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
|
{
|
||||||
|
internal partial class ParsedMatch<T>
|
||||||
|
{
|
||||||
|
public int StartIndex { get; }
|
||||||
|
|
||||||
|
public int Length { get; }
|
||||||
|
|
||||||
|
public T Value { get; }
|
||||||
|
|
||||||
|
public ParsedMatch(int startIndex, int length, T value)
|
||||||
|
{
|
||||||
|
StartIndex = startIndex;
|
||||||
|
Length = length;
|
||||||
|
Value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
|
{
|
||||||
|
internal class RegexMatcher<T> : IMatcher<T>
|
||||||
|
{
|
||||||
|
private readonly Regex _regex;
|
||||||
|
private readonly Func<Match, T> _transform;
|
||||||
|
|
||||||
|
public RegexMatcher(Regex regex, Func<Match, T> transform)
|
||||||
|
{
|
||||||
|
_regex = regex;
|
||||||
|
_transform = transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ParsedMatch<T> Match(string input, int startIndex, int length)
|
||||||
|
{
|
||||||
|
var match = _regex.Match(input, startIndex, length);
|
||||||
|
return match.Success ? new ParsedMatch<T>(match.Index, match.Length, _transform(match)) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
|
{
|
||||||
|
internal class StringMatcher<T> : IMatcher<T>
|
||||||
|
{
|
||||||
|
private readonly string _needle;
|
||||||
|
private readonly StringComparison _comparison;
|
||||||
|
private readonly Func<string, T> _transform;
|
||||||
|
|
||||||
|
public StringMatcher(string needle, StringComparison comparison, Func<string, T> transform)
|
||||||
|
{
|
||||||
|
_needle = needle;
|
||||||
|
_comparison = comparison;
|
||||||
|
_transform = transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringMatcher(string needle, Func<string, T> transform)
|
||||||
|
: this(needle, StringComparison.Ordinal, transform)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ParsedMatch<T> Match(string input, int startIndex, int length)
|
||||||
|
{
|
||||||
|
var index = input.IndexOf(_needle, startIndex, length, _comparison);
|
||||||
|
return index >= 0 ? new ParsedMatch<T>(index, _needle.Length, _transform(_needle)) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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
|
||||||
|
{
|
||||||
|
private const RegexOptions DefaultRegexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant;
|
||||||
|
|
||||||
|
/* 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),
|
||||||
|
m => new FormattedNode(m.Value, "**", TextFormatting.Bold, Parse(m.Groups[1].Value)));
|
||||||
|
|
||||||
|
// 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 preceeded by whitespace
|
||||||
|
private static readonly IMatcher<Node> ItalicFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||||
|
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value)));
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
m => new FormattedNode(m.Value, "*", TextFormatting.Italic, Parse(m.Groups[1].Value, 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),
|
||||||
|
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value)));
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
m => new FormattedNode(m.Value, "__", TextFormatting.Underline, Parse(m.Groups[1].Value)));
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
m => new FormattedNode(m.Value, "_", TextFormatting.Italic, Parse(m.Groups[1].Value, UnderlineFormattedNodeMatcher)));
|
||||||
|
|
||||||
|
// Capture any character until the earliest double tilde
|
||||||
|
private static readonly IMatcher<Node> StrikethroughFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||||
|
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
m => new FormattedNode(m.Value, "~~", TextFormatting.Strikethrough, Parse(m.Groups[1].Value)));
|
||||||
|
|
||||||
|
// Capture any character until the earliest double pipe
|
||||||
|
private static readonly IMatcher<Node> SpoilerFormattedNodeMatcher = new RegexMatcher<Node>(
|
||||||
|
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, Parse(m.Groups[1].Value)));
|
||||||
|
|
||||||
|
/* Code blocks */
|
||||||
|
|
||||||
|
// Capture any character except backtick until a backtick
|
||||||
|
// Whitespace surrounding content inside backticks is trimmed
|
||||||
|
private static readonly IMatcher<Node> InlineCodeBlockNodeMatcher = new RegexMatcher<Node>(
|
||||||
|
new Regex("`([^`]+)`", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
m => new InlineCodeBlockNode(m.Value, m.Groups[1].Value.Trim()));
|
||||||
|
|
||||||
|
// Capture language identifier and then any character until the earliest triple backtick
|
||||||
|
// Languge identifier is one word immediately after opening backticks, followed immediately by newline
|
||||||
|
// Whitespace surrounding content inside backticks is trimmed
|
||||||
|
private static readonly IMatcher<Node> MultilineCodeBlockNodeMatcher = new RegexMatcher<Node>(
|
||||||
|
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
|
m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value.Trim()));
|
||||||
|
|
||||||
|
/* Mentions */
|
||||||
|
|
||||||
|
// Capture @everyone
|
||||||
|
private static readonly IMatcher<Node> EveryoneMentionNodeMatcher = new StringMatcher<Node>(
|
||||||
|
"@everyone",
|
||||||
|
s => new MentionNode(s, "everyone", MentionType.Meta));
|
||||||
|
|
||||||
|
// Capture @here
|
||||||
|
private static readonly IMatcher<Node> HereMentionNodeMatcher = new StringMatcher<Node>(
|
||||||
|
"@here",
|
||||||
|
s => new MentionNode(s, "here", MentionType.Meta));
|
||||||
|
|
||||||
|
// Capture <@123456> or <@!123456>
|
||||||
|
private static readonly IMatcher<Node> UserMentionNodeMatcher = new RegexMatcher<Node>(
|
||||||
|
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
||||||
|
m => new MentionNode(m.Value, 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.Value, 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.Value, 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.Value, 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.Value, m.Groups[3].Value, m.Groups[2].Value, !m.Groups[1].Value.IsEmpty()));
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
|
||||||
|
// Capture [title](link)
|
||||||
|
private static readonly IMatcher<Node> TitledLinkNodeMatcher = new RegexMatcher<Node>(
|
||||||
|
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
||||||
|
m => new LinkNode(m.Value, 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.Value, 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.Value, 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>(
|
||||||
|
@"¯\_(ツ)_/¯",
|
||||||
|
s => new TextNode(s));
|
||||||
|
|
||||||
|
// Capture any "symbol/other" character or surrogate pair preceeded 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.Value, m.Groups[1].Value));
|
||||||
|
|
||||||
|
// Capture any non-whitespace, non latin alphanumeric character preceeded 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.Value, 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>(
|
||||||
|
ItalicBoldFormattedNodeMatcher,
|
||||||
|
ItalicUnderlineFormattedNodeMatcher,
|
||||||
|
BoldFormattedNodeMatcher,
|
||||||
|
ItalicFormattedNodeMatcher,
|
||||||
|
UnderlineFormattedNodeMatcher,
|
||||||
|
ItalicAltFormattedNodeMatcher,
|
||||||
|
StrikethroughFormattedNodeMatcher,
|
||||||
|
SpoilerFormattedNodeMatcher,
|
||||||
|
MultilineCodeBlockNodeMatcher,
|
||||||
|
InlineCodeBlockNodeMatcher,
|
||||||
|
EveryoneMentionNodeMatcher,
|
||||||
|
HereMentionNodeMatcher,
|
||||||
|
UserMentionNodeMatcher,
|
||||||
|
ChannelMentionNodeMatcher,
|
||||||
|
RoleMentionNodeMatcher,
|
||||||
|
StandardEmojiNodeMatcher,
|
||||||
|
CustomEmojiNodeMatcher,
|
||||||
|
TitledLinkNodeMatcher,
|
||||||
|
AutoLinkNodeMatcher,
|
||||||
|
HiddenLinkNodeMatcher,
|
||||||
|
ShrugTextNodeMatcher,
|
||||||
|
EscapedSymbolTextNodeMatcher,
|
||||||
|
EscapedCharacterTextNodeMatcher);
|
||||||
|
|
||||||
|
private static IReadOnlyList<Node> Parse(string input, IMatcher<Node> matcher) =>
|
||||||
|
matcher.MatchAll(input, s => new TextNode(s)).Select(r => r.Value).ToArray();
|
||||||
|
|
||||||
|
public static IReadOnlyList<Node> Parse(string input) => Parse(input, AggregateNodeMatcher);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public class EmojiNode : Node
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public bool IsAnimated { get; }
|
||||||
|
|
||||||
|
public bool IsCustomEmoji => Id != null;
|
||||||
|
|
||||||
|
public EmojiNode(string source, string id, string name, bool isAnimated)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
IsAnimated = isAnimated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EmojiNode(string source, string name)
|
||||||
|
: this(source, null, name, false)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"<Emoji> {Name}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
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 source, string token, TextFormatting formatting, IReadOnlyList<Node> children)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
Token = token;
|
||||||
|
Formatting = formatting;
|
||||||
|
Children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"<{Formatting}> ({Children.Count} direct children)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public class InlineCodeBlockNode : Node
|
||||||
|
{
|
||||||
|
public string Code { get; }
|
||||||
|
|
||||||
|
public InlineCodeBlockNode(string source, string code)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
Code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"<Code> {Code}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public class LinkNode : Node
|
||||||
|
{
|
||||||
|
public string Url { get; }
|
||||||
|
|
||||||
|
public string Title { get; }
|
||||||
|
|
||||||
|
public LinkNode(string source, string url, string title)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
Url = url;
|
||||||
|
Title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LinkNode(string source, string url) : this(source, url, url)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"<Link> {Title}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public class MentionNode : Node
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public MentionType Type { get; }
|
||||||
|
|
||||||
|
public MentionNode(string source, string id, MentionType type)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"<{Type} mention> {Id}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public enum MentionType
|
||||||
|
{
|
||||||
|
Meta,
|
||||||
|
User,
|
||||||
|
Channel,
|
||||||
|
Role
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public class MultilineCodeBlockNode : Node
|
||||||
|
{
|
||||||
|
public string Language { get; }
|
||||||
|
|
||||||
|
public string Code { get; }
|
||||||
|
|
||||||
|
public MultilineCodeBlockNode(string source, string language, string code)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
Language = language;
|
||||||
|
Code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => $"<Code [{Language}]> {Code}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public abstract class Node
|
||||||
|
{
|
||||||
|
public string Source { get; }
|
||||||
|
|
||||||
|
protected Node(string source)
|
||||||
|
{
|
||||||
|
Source = source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public enum TextFormatting
|
||||||
|
{
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Underline,
|
||||||
|
Strikethrough,
|
||||||
|
Spoiler
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public class TextNode : Node
|
||||||
|
{
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public TextNode(string source, string text)
|
||||||
|
: base(source)
|
||||||
|
{
|
||||||
|
Text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TextNode(string text) : this(text, text)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ToString() => Text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||||
|
|
||||||
|
public partial class Attachment
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Url { get; }
|
||||||
|
|
||||||
|
public int? Width { get; }
|
||||||
|
|
||||||
|
public int? Height { get; }
|
||||||
|
|
||||||
|
public string FileName { get; }
|
||||||
|
|
||||||
|
public bool IsImage { get; }
|
||||||
|
|
||||||
|
public FileSize FileSize { get; }
|
||||||
|
|
||||||
|
public Attachment(string id, int? width, int? height, string url, string fileName, FileSize fileSize)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Url = url;
|
||||||
|
Width = width;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-3
@@ -6,6 +6,8 @@
|
|||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string ParentId { get; }
|
||||||
|
|
||||||
public string GuildId { get; }
|
public string GuildId { get; }
|
||||||
|
|
||||||
public string Name { get; }
|
public string Name { get; }
|
||||||
@@ -13,10 +15,11 @@
|
|||||||
public string Topic { get; }
|
public string Topic { get; }
|
||||||
|
|
||||||
public ChannelType Type { get; }
|
public ChannelType Type { get; }
|
||||||
|
|
||||||
public Channel(string id, string guildId, string name, string topic, ChannelType type)
|
public Channel(string id, string parentId, string guildId, string name, string topic, ChannelType type)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
|
ParentId = parentId;
|
||||||
GuildId = guildId;
|
GuildId = guildId;
|
||||||
Name = name;
|
Name = name;
|
||||||
Topic = topic;
|
Topic = topic;
|
||||||
@@ -29,6 +32,6 @@
|
|||||||
public partial class Channel
|
public partial class Channel
|
||||||
{
|
{
|
||||||
public static Channel CreateDeletedChannel(string id) =>
|
public static Channel CreateDeletedChannel(string id) =>
|
||||||
new Channel(id, null, "deleted-channel", null, ChannelType.GuildTextChat);
|
new Channel(id, null, null, "deleted-channel", null, ChannelType.GuildTextChat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+5
-5
@@ -9,21 +9,21 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public Channel Channel { get; }
|
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 IReadOnlyList<Message> Messages { get; }
|
||||||
|
|
||||||
public Mentionables Mentionables { 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)
|
IReadOnlyList<Message> messages, Mentionables mentionables)
|
||||||
{
|
{
|
||||||
Guild = guild;
|
Guild = guild;
|
||||||
Channel = channel;
|
Channel = channel;
|
||||||
From = from;
|
After = after;
|
||||||
To = to;
|
Before = before;
|
||||||
Messages = messages;
|
Messages = messages;
|
||||||
Mentionables = mentionables;
|
Mentionables = mentionables;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -12,7 +12,7 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public string Url { get; }
|
public string Url { get; }
|
||||||
|
|
||||||
public DateTime? Timestamp { get; }
|
public DateTimeOffset? Timestamp { get; }
|
||||||
|
|
||||||
public Color Color { get; }
|
public Color Color { get; }
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public EmbedFooter Footer { get; }
|
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)
|
IReadOnlyList<EmbedField> fields, EmbedImage thumbnail, EmbedImage image, EmbedFooter footer)
|
||||||
{
|
{
|
||||||
Title = title;
|
Title = title;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
// https://discordapp.com/developers/docs/resources/emoji#emoji-object
|
||||||
|
|
||||||
|
public partial class Emoji
|
||||||
|
{
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public bool IsAnimated { get; }
|
||||||
|
|
||||||
|
public string ImageUrl { get; }
|
||||||
|
|
||||||
|
public Emoji(string id, string name, bool isAnimated)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Name = name;
|
||||||
|
IsAnimated = isAnimated;
|
||||||
|
|
||||||
|
ImageUrl = GetImageUrl(id, name, isAnimated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class Emoji
|
||||||
|
{
|
||||||
|
private static IEnumerable<int> GetCodePoints(string emoji)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < emoji.Length; i += char.IsHighSurrogate(emoji[i]) ? 2 : 1)
|
||||||
|
yield return char.ConvertToUtf32(emoji, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != null)
|
||||||
|
{
|
||||||
|
// 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,35 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
{
|
||||||
|
public static class Extensions
|
||||||
|
{
|
||||||
|
public static string GetFileExtension(this ExportFormat format)
|
||||||
|
{
|
||||||
|
if (format == ExportFormat.PlainText)
|
||||||
|
return "txt";
|
||||||
|
if (format == ExportFormat.HtmlDark)
|
||||||
|
return "html";
|
||||||
|
if (format == ExportFormat.HtmlLight)
|
||||||
|
return "html";
|
||||||
|
if (format == ExportFormat.Csv)
|
||||||
|
return "csv";
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(format));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDisplayName(this ExportFormat format)
|
||||||
|
{
|
||||||
|
if (format == ExportFormat.PlainText)
|
||||||
|
return "Plain Text";
|
||||||
|
if (format == ExportFormat.HtmlDark)
|
||||||
|
return "HTML (Dark)";
|
||||||
|
if (format == ExportFormat.HtmlLight)
|
||||||
|
return "HTML (Light)";
|
||||||
|
if (format == ExportFormat.Csv)
|
||||||
|
return "Comma Seperated Values (CSV)";
|
||||||
|
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(format));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-6
@@ -1,6 +1,4 @@
|
|||||||
using Tyrrrz.Extensions;
|
namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
// https://discordapp.com/developers/docs/resources/guild#guild-object
|
||||||
|
|
||||||
@@ -12,15 +10,15 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public string IconHash { get; }
|
public string IconHash { get; }
|
||||||
|
|
||||||
public string IconUrl => IconHash.IsNotBlank()
|
public string IconUrl { get; }
|
||||||
? $"https://cdn.discordapp.com/icons/{Id}/{IconHash}.png"
|
|
||||||
: "https://cdn.discordapp.com/embed/avatars/0.png";
|
|
||||||
|
|
||||||
public Guild(string id, string name, string iconHash)
|
public Guild(string id, string name, string iconHash)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
IconHash = iconHash;
|
IconHash = iconHash;
|
||||||
|
|
||||||
|
IconUrl = GetIconUrl(id, iconHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => Name;
|
public override string ToString() => Name;
|
||||||
@@ -28,6 +26,13 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public partial class Guild
|
public partial class Guild
|
||||||
{
|
{
|
||||||
|
public static string GetIconUrl(string id, string iconHash)
|
||||||
|
{
|
||||||
|
return iconHash != null
|
||||||
|
? $"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);
|
public static Guild DirectMessages { get; } = new Guild("@me", "Direct Messages", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-4
@@ -15,9 +15,9 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public User Author { get; }
|
public User Author { get; }
|
||||||
|
|
||||||
public DateTime Timestamp { get; }
|
public DateTimeOffset Timestamp { get; }
|
||||||
|
|
||||||
public DateTime? EditedTimestamp { get; }
|
public DateTimeOffset? EditedTimestamp { get; }
|
||||||
|
|
||||||
public string Content { get; }
|
public string Content { get; }
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ namespace DiscordChatExporter.Core.Models
|
|||||||
|
|
||||||
public IReadOnlyList<User> MentionedUsers { get; }
|
public IReadOnlyList<User> MentionedUsers { get; }
|
||||||
|
|
||||||
public Message(string id, string channelId, MessageType type, User author, DateTime timestamp,
|
public Message(string id, string channelId, MessageType type, User author, DateTimeOffset timestamp,
|
||||||
DateTime? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
DateTimeOffset? editedTimestamp, string content, IReadOnlyList<Attachment> attachments,
|
||||||
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
|
IReadOnlyList<Embed> embeds, IReadOnlyList<Reaction> reactions, IReadOnlyList<User> mentionedUsers)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
+3
-6
@@ -6,15 +6,12 @@
|
|||||||
{
|
{
|
||||||
public int Count { get; }
|
public int Count { get; }
|
||||||
|
|
||||||
public string EmojiId { get; }
|
public Emoji Emoji { get; }
|
||||||
|
|
||||||
public string EmojiName { get; }
|
public Reaction(int count, Emoji emoji)
|
||||||
|
|
||||||
public Reaction(int count, string emojiId, string emojiName)
|
|
||||||
{
|
{
|
||||||
Count = count;
|
Count = count;
|
||||||
EmojiId = emojiId;
|
Emoji = emoji;
|
||||||
EmojiName = emojiName;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
|
|
||||||
public partial class Role
|
public partial class Role
|
||||||
{
|
{
|
||||||
public static Role CreateDeletedRole(string id) =>
|
public static Role CreateDeletedRole(string id) => new Role(id, "deleted-role");
|
||||||
new Role(id, "deleted-role");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
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 User(string id, int discriminator, string name, string avatarHash)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
Discriminator = discriminator;
|
||||||
|
Name = name;
|
||||||
|
AvatarHash = avatarHash;
|
||||||
|
|
||||||
|
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 != null)
|
||||||
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
// Formatted node
|
||||||
|
if (node is FormattedNode formattedNode)
|
||||||
|
{
|
||||||
|
// Recursively get inner text
|
||||||
|
var innerText = FormatMarkdown(formattedNode.Children);
|
||||||
|
|
||||||
|
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-meta mention node
|
||||||
|
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
|
||||||
|
{
|
||||||
|
// 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emoji node
|
||||||
|
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
|
||||||
|
{
|
||||||
|
return $":{emojiNode.Name}:";
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other nodes - simply return source
|
||||||
|
return node.Source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
|
||||||
|
|
||||||
|
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(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
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Line break
|
||||||
|
await writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RenderAsync(TextWriter writer)
|
||||||
|
{
|
||||||
|
// Headers
|
||||||
|
await writer.WriteLineAsync("Author;Date;Content;Attachments;");
|
||||||
|
|
||||||
|
// Log
|
||||||
|
foreach (var message in _chatLog.Messages)
|
||||||
|
await RenderMessageAsync(writer, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||||
|
</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.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
|
||||||
|
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
+6
-6
@@ -1,20 +1,20 @@
|
|||||||
using System;
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
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
|
private class MessageGroup
|
||||||
{
|
{
|
||||||
public User Author { get; }
|
public User Author { get; }
|
||||||
|
|
||||||
public DateTime Timestamp { get; }
|
public DateTimeOffset Timestamp { get; }
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { 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;
|
Author = author;
|
||||||
Timestamp = timestamp;
|
Timestamp = timestamp;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using Scriban.Parsing;
|
||||||
|
using Scriban.Runtime;
|
||||||
|
using Scriban;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
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,198 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
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 isTopLevel, bool isSingle)
|
||||||
|
{
|
||||||
|
// 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>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 language class for syntax highlighting
|
||||||
|
var languageCssClass = multilineCodeBlockNode.Language != null
|
||||||
|
? "language-" + multilineCodeBlockNode.Language
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return $"<div class=\"pre pre--multiline {languageCssClass}\">{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);
|
||||||
|
|
||||||
|
// Emoji can be jumboable if it's the only top-level node
|
||||||
|
var jumboableCssClass = isTopLevel && isSingle ? "emoji--large" : null;
|
||||||
|
|
||||||
|
return $"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link node
|
||||||
|
if (node is LinkNode linkNode)
|
||||||
|
{
|
||||||
|
return $"<a href=\"{Uri.EscapeUriString(linkNode.Url)}\">{HtmlEncode(linkNode.Title)}</a>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other nodes - simply return source
|
||||||
|
return node.Source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
|
||||||
|
{
|
||||||
|
var isSingle = nodes.Count == 1;
|
||||||
|
return nodes.Select(n => FormatMarkdown(n, isTopLevel, isSingle)).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,129 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
// Formatted node
|
||||||
|
if (node is FormattedNode formattedNode)
|
||||||
|
{
|
||||||
|
// Recursively get inner text
|
||||||
|
var innerText = FormatMarkdown(formattedNode.Children);
|
||||||
|
|
||||||
|
return $"{formattedNode.Token}{innerText}{formattedNode.Token}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-meta mention node
|
||||||
|
if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
|
||||||
|
{
|
||||||
|
// 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom emoji node
|
||||||
|
if (node is EmojiNode emojiNode && emojiNode.IsCustomEmoji)
|
||||||
|
{
|
||||||
|
return $":{emojiNode.Name}:";
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other nodes - simply return source
|
||||||
|
return node.Source;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
|
||||||
|
|
||||||
|
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown));
|
||||||
|
|
||||||
|
private async Task RenderMessageAsync(TextWriter writer, Message message)
|
||||||
|
{
|
||||||
|
// Timestamp and author
|
||||||
|
await writer.WriteLineAsync($"[{FormatDate(message.Timestamp)}] {message.Author.FullName}");
|
||||||
|
|
||||||
|
// Content
|
||||||
|
await writer.WriteLineAsync(FormatMarkdown(message.Content));
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
foreach (var attachment in message.Attachments)
|
||||||
|
await writer.WriteLineAsync(attachment.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
await writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-15
@@ -2,24 +2,27 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #36393e;
|
background-color: #36393e;
|
||||||
color: #ffffffb3;
|
color: #dcddde;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #0096cf;
|
color: #0096cf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spoiler {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.pre {
|
.pre {
|
||||||
background-color: #2f3136;
|
background-color: #2f3136 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pre--multiline {
|
.pre--multiline {
|
||||||
border-color: #282b30;
|
border-color: #282b30 !important;
|
||||||
color: #839496;
|
color: #839496 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention {
|
.mention {
|
||||||
background-color: #738bd71a;
|
|
||||||
color: #7289da;
|
color: #7289da;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ a {
|
|||||||
/* === CHATLOG === */
|
/* === CHATLOG === */
|
||||||
|
|
||||||
.chatlog__message-group {
|
.chatlog__message-group {
|
||||||
border-color: #ffffff0a;
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__author-name {
|
.chatlog__author-name {
|
||||||
@@ -48,16 +51,16 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__timestamp {
|
.chatlog__timestamp {
|
||||||
color: #ffffff33;
|
color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__edited-timestamp {
|
.chatlog__edited-timestamp {
|
||||||
color: #ffffff33;
|
color: rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-content-container {
|
.chatlog__embed-content-container {
|
||||||
background-color: #2e30364d;
|
background-color: rgba(46, 48, 54, 0.3);
|
||||||
border-color: #2e303699;
|
border-color: rgba(46, 48, 54, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-name {
|
.chatlog__embed-author-name {
|
||||||
@@ -73,7 +76,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-description {
|
.chatlog__embed-description {
|
||||||
color: #ffffff99;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field-name {
|
.chatlog__embed-field-name {
|
||||||
@@ -81,17 +84,17 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field-value {
|
.chatlog__embed-field-value {
|
||||||
color: #ffffff99;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer {
|
.chatlog__embed-footer {
|
||||||
color: #ffffff99;
|
color: rgba(255, 255, 255, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction {
|
.chatlog__reaction {
|
||||||
background-color: #ffffff0a;
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction-count {
|
.chatlog__reaction-count {
|
||||||
color: #ffffff4d;
|
color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{~ ThemeStyleSheet = include "HtmlDark.css" ~}}
|
||||||
|
{{~ HighlightJsStyleName = "solarized-dark" ~}}
|
||||||
|
{{~ include "HtmlShared.html" ~}}
|
||||||
+12
-8
@@ -2,20 +2,24 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
color: #737f8d;
|
color: #747f8d;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #00b0f4;
|
color: #00b0f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.spoiler {
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.pre {
|
.pre {
|
||||||
background-color: #f9f9f9;
|
background-color: #f9f9f9 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pre--multiline {
|
.pre--multiline {
|
||||||
border-color: #f3f3f3;
|
border-color: #f3f3f3 !important;
|
||||||
color: #657b83;
|
color: #657b83 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mention {
|
.mention {
|
||||||
@@ -56,8 +60,8 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-content-container {
|
.chatlog__embed-content-container {
|
||||||
background-color: #f9f9f94d;
|
background-color: rgba(249, 249, 249, 0.3);
|
||||||
border-color: #cccccc4d;
|
border-color: rgba(204, 204, 204, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-name {
|
.chatlog__embed-author-name {
|
||||||
@@ -85,11 +89,11 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer {
|
.chatlog__embed-footer {
|
||||||
color: #4f535b99;
|
color: rgba(79, 83, 91, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction {
|
.chatlog__reaction {
|
||||||
background-color: #4f545c0f;
|
background-color: rgba(79, 84, 92, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction-count {
|
.chatlog__reaction-count {
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{~ ThemeStyleSheet = include "HtmlLight.css" ~}}
|
||||||
|
{{~ HighlightJsStyleName = "solarized-light" ~}}
|
||||||
|
{{~ include "HtmlShared.html" ~}}
|
||||||
+30
-21
@@ -17,9 +17,18 @@ img {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.3;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spoiler {
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.pre {
|
.pre {
|
||||||
font-family: "Consolas", "Courier New", Courier, Monospace;
|
font-family: "Consolas", "Courier New", Courier, Monospace;
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pre--multiline {
|
.pre--multiline {
|
||||||
@@ -34,25 +43,25 @@ img {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mention {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.emoji {
|
.emoji {
|
||||||
width: 24px;
|
width: 1.45em;
|
||||||
height: 24px;
|
height: 1.45em;
|
||||||
margin: 0 1px;
|
margin: 0 1px;
|
||||||
vertical-align: middle;
|
vertical-align: -0.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji--small {
|
.emoji--small {
|
||||||
width: 16px;
|
width: 1rem;
|
||||||
height: 16px;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emoji--large {
|
.emoji--large {
|
||||||
width: 32px;
|
width: 2rem;
|
||||||
height: 32px;
|
height: 2rem;
|
||||||
}
|
|
||||||
|
|
||||||
.mention {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === INFO === */
|
/* === INFO === */
|
||||||
@@ -101,6 +110,7 @@ img {
|
|||||||
|
|
||||||
.chatlog {
|
.chatlog {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__message-group {
|
.chatlog__message-group {
|
||||||
@@ -130,6 +140,7 @@ img {
|
|||||||
|
|
||||||
.chatlog__author-name {
|
.chatlog__author-name {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__timestamp {
|
.chatlog__timestamp {
|
||||||
@@ -144,7 +155,7 @@ img {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__edited-timestamp {
|
.chatlog__edited-timestamp {
|
||||||
margin-left: 5px;
|
margin-left: 3px;
|
||||||
font-size: .8em;
|
font-size: .8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,21 +294,19 @@ img {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reactions {
|
.chatlog__reactions {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction {
|
.chatlog__reaction {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin: 6px 2px 2px 2px;
|
margin: 6px 2px 2px 2px;
|
||||||
padding: 2px 6px 2px 2px;
|
padding: 3px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction-emoji {
|
|
||||||
margin-left: 3px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__reaction-count {
|
.chatlog__reaction-count {
|
||||||
|
min-width: 9px;
|
||||||
|
margin-left: 6px;
|
||||||
font-size: .875em;
|
font-size: .875em;
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
+35
-26
@@ -2,12 +2,29 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
{{~ # Metadata ~}}
|
||||||
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
|
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
|
|
||||||
|
{{~ # Styles ~}}
|
||||||
<style>
|
<style>
|
||||||
{{ StyleSheet }}
|
{{ include "HtmlShared.css" }}
|
||||||
</style>
|
</style>
|
||||||
|
<style>
|
||||||
|
{{ ThemeStyleSheet }}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{{~ # 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>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.pre--multiline').forEach((block) => {
|
||||||
|
hljs.highlightBlock(block);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -24,16 +41,16 @@
|
|||||||
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
|
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
|
|
||||||
<div class="info__channel-message-count">{{ Model.Messages | array.size | Format "N0" }} messages</div>
|
<div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div>
|
||||||
|
|
||||||
{{~ if Model.From || Model.To ~}}
|
{{~ if Model.After || Model.Before ~}}
|
||||||
<div class="info__channel-date-range">
|
<div class="info__channel-date-range">
|
||||||
{{~ if Model.From && Model.To ~}}
|
{{~ if Model.After && Model.Before ~}}
|
||||||
Between {{ Model.From | FormatDate | html.escape }} and {{ Model.To | FormatDate | html.escape }}
|
Between {{ Model.After | FormatDate | html.escape }} and {{ Model.Before | FormatDate | html.escape }}
|
||||||
{{~ else if Model.From ~}}
|
{{~ else if Model.After ~}}
|
||||||
After {{ Model.From | FormatDate | html.escape }}
|
After {{ Model.After | FormatDate | html.escape }}
|
||||||
{{~ else if Model.To ~}}
|
{{~ else if Model.Before ~}}
|
||||||
Before {{ Model.To | FormatDate | html.escape }}
|
Before {{ Model.Before | FormatDate | html.escape }}
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
@@ -58,7 +75,7 @@
|
|||||||
{{~ # Content ~}}
|
{{~ # Content ~}}
|
||||||
{{~ if message.Content ~}}
|
{{~ if message.Content ~}}
|
||||||
<div class="chatlog__content">
|
<div class="chatlog__content">
|
||||||
{{ message.Content | FormatContent }}
|
<span class="markdown">{{ message.Content | FormatMarkdown }}</span>
|
||||||
|
|
||||||
{{~ # Edited timestamp ~}}
|
{{~ # Edited timestamp ~}}
|
||||||
{{~ if message.EditedTimestamp ~}}
|
{{~ if message.EditedTimestamp ~}}
|
||||||
@@ -76,7 +93,7 @@
|
|||||||
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
|
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
|
||||||
{{~ # Non-image ~}}
|
{{~ # Non-image ~}}
|
||||||
{{~ else ~}}
|
{{~ else ~}}
|
||||||
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize | FormatFileSize }})
|
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +102,7 @@
|
|||||||
{{~ # Embeds ~}}
|
{{~ # Embeds ~}}
|
||||||
{{~ for embed in message.Embeds ~}}
|
{{~ for embed in message.Embeds ~}}
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color | FormatColor }})"></div>
|
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
|
||||||
<div class="chatlog__embed-content-container">
|
<div class="chatlog__embed-content-container">
|
||||||
<div class="chatlog__embed-content">
|
<div class="chatlog__embed-content">
|
||||||
<div class="chatlog__embed-text">
|
<div class="chatlog__embed-text">
|
||||||
@@ -112,16 +129,16 @@
|
|||||||
{{~ if embed.Title ~}}
|
{{~ if embed.Title ~}}
|
||||||
<div class="chatlog__embed-title">
|
<div class="chatlog__embed-title">
|
||||||
{{~ if embed.Url ~}}
|
{{~ if embed.Url ~}}
|
||||||
<a class="chatlog__embed-title-link" href="{{ embed.Url }}">{{ embed.Title | FormatContent }}</a>
|
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
|
||||||
{{~ else ~}}
|
{{~ else ~}}
|
||||||
{{ embed.Title | FormatContent }}
|
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Description ~}}
|
{{~ # Description ~}}
|
||||||
{{~ if embed.Description ~}}
|
{{~ if embed.Description ~}}
|
||||||
<div class="chatlog__embed-description">{{ embed.Description | FormatContent true }}</div>
|
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
|
|
||||||
{{~ # Fields ~}}
|
{{~ # Fields ~}}
|
||||||
@@ -130,10 +147,10 @@
|
|||||||
{{~ for field in embed.Fields ~}}
|
{{~ for field in embed.Fields ~}}
|
||||||
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
||||||
{{~ if field.Name ~}}
|
{{~ if field.Name ~}}
|
||||||
<div class="chatlog__embed-field-name">{{ field.Name | FormatContent }}</div>
|
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
{{~ if field.Value ~}}
|
{{~ if field.Value ~}}
|
||||||
<div class="chatlog__embed-field-value">{{ field.Value | FormatContent true }}</div>
|
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
</div>
|
</div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
@@ -192,15 +209,7 @@
|
|||||||
<div class="chatlog__reactions">
|
<div class="chatlog__reactions">
|
||||||
{{~ for reaction in message.Reactions ~}}
|
{{~ for reaction in message.Reactions ~}}
|
||||||
<div class="chatlog__reaction">
|
<div class="chatlog__reaction">
|
||||||
<span class="chatlog__reaction-emoji">
|
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}" />
|
||||||
{{~ # Custom emoji ~}}
|
|
||||||
{{~ if reaction.EmojiId ~}}
|
|
||||||
<img class="emoji emoji--small" title="{{ reaction.EmojiName }}" src="https://cdn.discordapp.com/emojis/{{ reaction.EmojiId }}.png" />
|
|
||||||
{{~ # Default emoji ~}}
|
|
||||||
{{~ else ~}}
|
|
||||||
{{ reaction.EmojiName }}
|
|
||||||
{{~ end ~}}
|
|
||||||
</span>
|
|
||||||
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
||||||
</div>
|
</div>
|
||||||
{{~ end ~}}
|
{{~ end ~}}
|
||||||
+22
-11
@@ -1,8 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DiscordChatExporter.Core.Internal;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using DiscordChatExporter.Core.Services.Internal;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
{
|
{
|
||||||
// Get basic data
|
// Get basic data
|
||||||
var id = json["id"].Value<string>();
|
var id = json["id"].Value<string>();
|
||||||
|
var parentId = json["parent_id"]?.Value<string>();
|
||||||
var type = (ChannelType) json["type"].Value<int>();
|
var type = (ChannelType) json["type"].Value<int>();
|
||||||
var topic = json["topic"]?.Value<string>();
|
var topic = json["topic"]?.Value<string>();
|
||||||
|
|
||||||
@@ -40,17 +41,17 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
var guildId = json["guild_id"]?.Value<string>();
|
var guildId = json["guild_id"]?.Value<string>();
|
||||||
|
|
||||||
// If the guild ID is blank, it's direct messages
|
// If the guild ID is blank, it's direct messages
|
||||||
if (guildId.IsBlank())
|
if (guildId == null)
|
||||||
guildId = Guild.DirectMessages.Id;
|
guildId = Guild.DirectMessages.Id;
|
||||||
|
|
||||||
// Try to extract name
|
// Try to extract name
|
||||||
var name = json["name"]?.Value<string>();
|
var name = json["name"]?.Value<string>();
|
||||||
|
|
||||||
// If the name is blank, it's direct messages
|
// If the name is blank, it's direct messages
|
||||||
if (name.IsBlank())
|
if (name == null)
|
||||||
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
|
name = json["recipients"].Select(ParseUser).Select(u => u.Name).JoinToString(", ");
|
||||||
|
|
||||||
return new Channel(id, guildId, name, topic, type);
|
return new Channel(id, parentId, guildId, name, topic, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Role ParseRole(JToken json)
|
private Role ParseRole(JToken json)
|
||||||
@@ -68,7 +69,9 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
var width = json["width"]?.Value<int>();
|
var width = json["width"]?.Value<int>();
|
||||||
var height = json["height"]?.Value<int>();
|
var height = json["height"]?.Value<int>();
|
||||||
var fileName = json["filename"].Value<string>();
|
var fileName = json["filename"].Value<string>();
|
||||||
var fileSize = json["size"].Value<long>();
|
var fileSizeBytes = json["size"].Value<long>();
|
||||||
|
|
||||||
|
var fileSize = new FileSize(fileSizeBytes);
|
||||||
|
|
||||||
return new Attachment(id, width, height, url, fileName, fileSize);
|
return new Attachment(id, width, height, url, fileName, fileSize);
|
||||||
}
|
}
|
||||||
@@ -114,7 +117,7 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
var title = json["title"]?.Value<string>();
|
var title = json["title"]?.Value<string>();
|
||||||
var description = json["description"]?.Value<string>();
|
var description = json["description"]?.Value<string>();
|
||||||
var url = json["url"]?.Value<string>();
|
var url = json["url"]?.Value<string>();
|
||||||
var timestamp = json["timestamp"]?.Value<DateTime>();
|
var timestamp = json["timestamp"]?.Value<DateTimeOffset>();
|
||||||
|
|
||||||
// Get color
|
// Get color
|
||||||
var color = json["color"] != null
|
var color = json["color"] != null
|
||||||
@@ -139,13 +142,21 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
|
return new Embed(title, url, timestamp, color, author, description, fields, thumbnail, image, footer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Emoji ParseEmoji(JToken json)
|
||||||
|
{
|
||||||
|
var id = json["id"]?.Value<string>();
|
||||||
|
var name = json["name"]?.Value<string>();
|
||||||
|
var isAnimated = json["animated"]?.Value<bool>() ?? false;
|
||||||
|
|
||||||
|
return new Emoji(id, name, isAnimated);
|
||||||
|
}
|
||||||
|
|
||||||
private Reaction ParseReaction(JToken json)
|
private Reaction ParseReaction(JToken json)
|
||||||
{
|
{
|
||||||
var count = json["count"].Value<int>();
|
var count = json["count"].Value<int>();
|
||||||
var emojiId = json["emoji"]["id"]?.Value<string>();
|
var emoji = ParseEmoji(json["emoji"]);
|
||||||
var emojiName = json["emoji"]["name"].Value<string>();
|
|
||||||
|
|
||||||
return new Reaction(count, emojiId, emojiName);
|
return new Reaction(count, emoji);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Message ParseMessage(JToken json)
|
private Message ParseMessage(JToken json)
|
||||||
@@ -153,8 +164,8 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
// Get basic data
|
// Get basic data
|
||||||
var id = json["id"].Value<string>();
|
var id = json["id"].Value<string>();
|
||||||
var channelId = json["channel_id"].Value<string>();
|
var channelId = json["channel_id"].Value<string>();
|
||||||
var timestamp = json["timestamp"].Value<DateTime>();
|
var timestamp = json["timestamp"].Value<DateTimeOffset>();
|
||||||
var editedTimestamp = json["edited_timestamp"]?.Value<DateTime?>();
|
var editedTimestamp = json["edited_timestamp"]?.Value<DateTimeOffset?>();
|
||||||
var content = json["content"].Value<string>();
|
var content = json["content"].Value<string>();
|
||||||
var type = (MessageType) json["type"].Value<int>();
|
var type = (MessageType) json["type"].Value<int>();
|
||||||
|
|
||||||
+44
-30
@@ -1,32 +1,36 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
|
using DiscordChatExporter.Core.Services.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Services.Internal;
|
||||||
|
using Failsafe;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using DiscordChatExporter.Core.Internal;
|
|
||||||
using Polly;
|
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
public partial class DataService : IDataService, IDisposable
|
public partial class DataService : IDisposable
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient = new HttpClient();
|
private readonly HttpClient _httpClient = new HttpClient();
|
||||||
|
|
||||||
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
|
private async Task<JToken> GetApiResponseAsync(AuthToken token, string resource, string endpoint,
|
||||||
params string[] parameters)
|
params string[] parameters)
|
||||||
{
|
{
|
||||||
// Create request policy
|
// Create retry policy
|
||||||
var policy = Policy
|
var retry = Retry.Create()
|
||||||
.Handle<HttpErrorStatusCodeException>(e => (int) e.StatusCode == 429)
|
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode >= 500)
|
||||||
.WaitAndRetryAsync(10, i => TimeSpan.FromSeconds(0.4));
|
.Catch<HttpErrorStatusCodeException>(false, e => (int) e.StatusCode == 429)
|
||||||
|
.WithMaxTryCount(10)
|
||||||
|
.WithDelay(TimeSpan.FromSeconds(0.4));
|
||||||
|
|
||||||
// Send request
|
// Send request
|
||||||
return await policy.ExecuteAsync(async () =>
|
return await retry.ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
// Create request
|
// Create request
|
||||||
const string apiRoot = "https://discordapp.com/api/v6";
|
const string apiRoot = "https://discordapp.com/api/v6";
|
||||||
@@ -42,6 +46,11 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
{
|
{
|
||||||
var key = parameter.SubstringUntil("=");
|
var key = parameter.SubstringUntil("=");
|
||||||
var value = parameter.SubstringAfter("=");
|
var value = parameter.SubstringAfter("=");
|
||||||
|
|
||||||
|
// Skip empty values
|
||||||
|
if (value.IsEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +66,12 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
var raw = await response.Content.ReadAsStringAsync();
|
var raw = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// Parse
|
// Parse
|
||||||
return JToken.Parse(raw);
|
using (var reader = new JsonTextReader(new StringReader(raw)))
|
||||||
|
{
|
||||||
|
reader.DateParseHandling = DateParseHandling.DateTimeOffset;
|
||||||
|
|
||||||
|
return JToken.Load(reader);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -116,31 +130,24 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
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>();
|
var result = new List<Message>();
|
||||||
|
|
||||||
// Report indeterminate progress
|
|
||||||
progress?.Report(-1);
|
|
||||||
|
|
||||||
// Get the snowflakes for the selected range
|
|
||||||
var firstId = from != null ? from.Value.ToSnowflake() : "0";
|
|
||||||
var lastId = to != null ? to.Value.ToSnowflake() : DateTime.MaxValue.ToSnowflake();
|
|
||||||
|
|
||||||
// Get the last message
|
// Get the last message
|
||||||
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
var response = await GetApiResponseAsync(token, "channels", $"{channelId}/messages",
|
||||||
"limit=1", $"before={lastId}");
|
"limit=1", $"before={before?.ToSnowflake()}");
|
||||||
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
|
var lastMessage = response.Select(ParseMessage).FirstOrDefault();
|
||||||
|
|
||||||
// If the last message doesn't exist or it's outside of range - return
|
// 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);
|
progress?.Report(1);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get other messages
|
// Get other messages
|
||||||
var offsetId = firstId;
|
var offsetId = after?.ToSnowflake() ?? "0";
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
// Get message batch
|
// Get message batch
|
||||||
@@ -215,30 +222,37 @@ namespace DiscordChatExporter.Core.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
|
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
|
// Get messages
|
||||||
var messages = await GetChannelMessagesAsync(token, channel.Id, from, to, progress);
|
var messages = await GetChannelMessagesAsync(token, channel.Id, after, before, progress);
|
||||||
|
|
||||||
// Get mentionables
|
// Get mentionables
|
||||||
var mentionables = await GetMentionablesAsync(token, guild.Id, messages);
|
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, string channelId,
|
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 channel
|
|
||||||
var channel = await GetChannelAsync(token, channelId);
|
|
||||||
|
|
||||||
// Get guild
|
// Get guild
|
||||||
var guild = channel.GuildId == Guild.DirectMessages.Id
|
var guild = channel.GuildId == Guild.DirectMessages.Id
|
||||||
? Guild.DirectMessages
|
? Guild.DirectMessages
|
||||||
: await GetGuildAsync(token, channel.GuildId);
|
: await GetGuildAsync(token, channel.GuildId);
|
||||||
|
|
||||||
// Get the chat log
|
// 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,
|
||||||
|
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, after, before, progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Failsafe" Version="1.1.0" />
|
||||||
|
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
|
||||||
|
<PackageReference Include="Onova" Version="2.4.2" />
|
||||||
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" />
|
||||||
|
<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;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exceptions
|
namespace DiscordChatExporter.Core.Services.Exceptions
|
||||||
{
|
{
|
||||||
public class HttpErrorStatusCodeException : Exception
|
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.EmptyIfNull().IsWhiteSpace())
|
||||||
|
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.EmptyIfNull().IsWhiteSpace())
|
||||||
|
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
|
||||||
|
|
||||||
|
// Export
|
||||||
|
await ExportChatLogAsync(partition, partitionFilePath, format);
|
||||||
|
|
||||||
|
// Increment partition number
|
||||||
|
partitionNumber++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using DiscordChatExporter.Core.Models;
|
||||||
|
|
||||||
|
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) == null;
|
||||||
|
|
||||||
|
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
|
||||||
|
DateTimeOffset? after = null, DateTimeOffset? before = null)
|
||||||
|
{
|
||||||
|
var result = new StringBuilder();
|
||||||
|
|
||||||
|
// Append guild and channel names
|
||||||
|
result.Append($"{guild.Name} - {channel.Name} [{channel.Id}]");
|
||||||
|
|
||||||
|
// Append date range
|
||||||
|
if (after != null || before != null)
|
||||||
|
{
|
||||||
|
result.Append(" (");
|
||||||
|
|
||||||
|
// Both 'after' and 'before' are set
|
||||||
|
if (after != null && before != null)
|
||||||
|
{
|
||||||
|
result.Append($"{after:yyyy-MM-dd} to {before:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
// Only 'after' is set
|
||||||
|
else if (after != null)
|
||||||
|
{
|
||||||
|
result.Append($"after {after:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
// Only 'before' is set
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Append($"before {before:yyyy-MM-dd}");
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Append(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append extension
|
||||||
|
result.Append($".{format.GetFileExtension()}");
|
||||||
|
|
||||||
|
// Replace invalid chars
|
||||||
|
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||||
|
result.Replace(invalidChar, '_');
|
||||||
|
|
||||||
|
return result.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services.Internal
|
||||||
|
{
|
||||||
|
internal static class Extensions
|
||||||
|
{
|
||||||
|
public static string ToSnowflake(this DateTimeOffset date)
|
||||||
|
{
|
||||||
|
const long epoch = 62135596800000;
|
||||||
|
var unixTime = date.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
|
||||||
|
var value = ((ulong) unixTime - 1420070400000UL) << 22;
|
||||||
|
return value.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -3,12 +3,11 @@ using Tyrrrz.Settings;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Core.Services
|
||||||
{
|
{
|
||||||
public class SettingsService : SettingsManager, ISettingsService
|
public class SettingsService : SettingsManager
|
||||||
{
|
{
|
||||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
|
|
||||||
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm tt";
|
||||||
public int MessageGroupLimit { get; set; } = 20;
|
|
||||||
|
|
||||||
public AuthToken LastToken { get; set; }
|
public AuthToken LastToken { get; set; }
|
||||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Onova;
|
||||||
|
using Onova.Exceptions;
|
||||||
|
using Onova.Services;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
{
|
||||||
|
public class UpdateService : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SettingsService _settingsService;
|
||||||
|
|
||||||
|
private readonly IUpdateManager _updateManager = new UpdateManager(
|
||||||
|
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
||||||
|
new ZipPackageExtractor());
|
||||||
|
|
||||||
|
private Version _updateVersion;
|
||||||
|
private bool _updaterLaunched;
|
||||||
|
|
||||||
|
public UpdateService(SettingsService settingsService)
|
||||||
|
{
|
||||||
|
_settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Version> CheckPrepareUpdateAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// If auto-update is disabled - don't check for updates
|
||||||
|
if (!_settingsService.IsAutoUpdateEnabled)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Check for updates
|
||||||
|
var check = await _updateManager.CheckForUpdatesAsync();
|
||||||
|
if (!check.CanUpdate)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Prepare the update
|
||||||
|
await _updateManager.PrepareUpdateAsync(check.LastVersion);
|
||||||
|
|
||||||
|
return _updateVersion = check.LastVersion;
|
||||||
|
}
|
||||||
|
catch (UpdaterAlreadyLaunchedException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (LockFileNotAcquiredException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void FinalizeUpdate(bool needRestart)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Check if an update is pending
|
||||||
|
if (_updateVersion == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check if the updater has already been launched
|
||||||
|
if (_updaterLaunched)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Launch the updater
|
||||||
|
_updateManager.LaunchUpdater(_updateVersion, needRestart);
|
||||||
|
_updaterLaunched = true;
|
||||||
|
}
|
||||||
|
catch (UpdaterAlreadyLaunchedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (LockFileNotAcquiredException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _updateManager.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net461</TargetFramework>
|
|
||||||
<Version>2.8</Version>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\PlainText.txt" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark.html" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight.html" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\Core.html" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\Shared.css" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\DarkTheme.css" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\Html\LightTheme.css" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\Csv.csv" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Reference Include="mscorlib" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
|
|
||||||
<PackageReference Include="Onova" Version="2.1.0" />
|
|
||||||
<PackageReference Include="Polly" Version="6.0.1" />
|
|
||||||
<PackageReference Include="Scriban" Version="1.2.1" />
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.2" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Drawing;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Internal
|
|
||||||
{
|
|
||||||
internal static class Extensions
|
|
||||||
{
|
|
||||||
public static string ToSnowflake(this DateTime dateTime)
|
|
||||||
{
|
|
||||||
const long epoch = 62135596800000;
|
|
||||||
var unixTime = dateTime.ToUniversalTime().Ticks / TimeSpan.TicksPerMillisecond - epoch;
|
|
||||||
var value = ((ulong) unixTime - 1420070400000UL) << 22;
|
|
||||||
return value.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string Base64Encode(this string str) => str.GetBytes().ToBase64();
|
|
||||||
|
|
||||||
public static string Base64Decode(this string str) => str.FromBase64().GetString();
|
|
||||||
|
|
||||||
public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
namespace DiscordChatExporter.Core.Models
|
|
||||||
{
|
|
||||||
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
|
||||||
|
|
||||||
public class Attachment
|
|
||||||
{
|
|
||||||
public string Id { get; }
|
|
||||||
|
|
||||||
public string Url { get; }
|
|
||||||
|
|
||||||
public int? Width { get; }
|
|
||||||
|
|
||||||
public int? Height { get; }
|
|
||||||
|
|
||||||
public bool IsImage => Width != null;
|
|
||||||
|
|
||||||
public string FileName { get; }
|
|
||||||
|
|
||||||
public long FileSize { get; }
|
|
||||||
|
|
||||||
public Attachment(string id, int? width, int? height, string url, string fileName, long fileSize)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Url = url;
|
|
||||||
Width = width;
|
|
||||||
Height = height;
|
|
||||||
FileName = fileName;
|
|
||||||
FileSize = fileSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => FileName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
|
||||||
{
|
|
||||||
public static class Extensions
|
|
||||||
{
|
|
||||||
public static string GetFileExtension(this ExportFormat format)
|
|
||||||
{
|
|
||||||
if (format == ExportFormat.PlainText)
|
|
||||||
return "txt";
|
|
||||||
if (format == ExportFormat.HtmlDark)
|
|
||||||
return "html";
|
|
||||||
if (format == ExportFormat.HtmlLight)
|
|
||||||
return "html";
|
|
||||||
if (format == ExportFormat.Csv)
|
|
||||||
return "csv";
|
|
||||||
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(format));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string GetDisplayName(this ExportFormat format)
|
|
||||||
{
|
|
||||||
if (format == ExportFormat.PlainText)
|
|
||||||
return "Plain Text";
|
|
||||||
if (format == ExportFormat.HtmlDark)
|
|
||||||
return "HTML (Dark)";
|
|
||||||
if (format == ExportFormat.HtmlLight)
|
|
||||||
return "HTML (Light)";
|
|
||||||
if (format == ExportFormat.Csv)
|
|
||||||
return "Comma Seperated Values (CSV)";
|
|
||||||
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(format));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IReadOnlyList<ChatLog> SplitIntoPartitions(this ChatLog chatLog, int partitionLimit)
|
|
||||||
{
|
|
||||||
// If chat log has fewer messages than the limit - just return chat log in a list
|
|
||||||
if (chatLog.Messages.Count <= partitionLimit)
|
|
||||||
return new[] {chatLog};
|
|
||||||
|
|
||||||
var result = new List<ChatLog>();
|
|
||||||
|
|
||||||
// Loop through messages
|
|
||||||
var buffer = new List<Message>();
|
|
||||||
foreach (var message in chatLog.Messages)
|
|
||||||
{
|
|
||||||
// Add message to buffer
|
|
||||||
buffer.Add(message);
|
|
||||||
|
|
||||||
// If reached the limit - split and reset buffer
|
|
||||||
if (buffer.Count >= partitionLimit)
|
|
||||||
{
|
|
||||||
// Add to result
|
|
||||||
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
|
|
||||||
chatLog.Mentionables);
|
|
||||||
result.Add(chatLogPartition);
|
|
||||||
|
|
||||||
// Reset the buffer instead of clearing to avoid mutations on existing references
|
|
||||||
buffer = new List<Message>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add what's remaining in buffer
|
|
||||||
if (buffer.Any())
|
|
||||||
{
|
|
||||||
// Add to result
|
|
||||||
var chatLogPartition = new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, buffer,
|
|
||||||
chatLog.Mentionables);
|
|
||||||
result.Add(chatLogPartition);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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 => $"{Name}#{Discriminator:0000}";
|
|
||||||
|
|
||||||
public string DefaultAvatarHash => $"{Discriminator % 5}";
|
|
||||||
|
|
||||||
public string AvatarHash { get; }
|
|
||||||
|
|
||||||
public bool IsAvatarAnimated =>
|
|
||||||
AvatarHash.IsNotBlank() && AvatarHash.StartsWith("a_", StringComparison.Ordinal);
|
|
||||||
|
|
||||||
public string AvatarUrl
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
// Custom avatar
|
|
||||||
if (AvatarHash.IsNotBlank())
|
|
||||||
{
|
|
||||||
// Animated
|
|
||||||
if (IsAvatarAnimated)
|
|
||||||
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/{DefaultAvatarHash}.png";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public User(string id, int discriminator, string name, string avatarHash)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Discriminator = discriminator;
|
|
||||||
Name = name;
|
|
||||||
AvatarHash = avatarHash;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => FullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class User
|
|
||||||
{
|
|
||||||
public static User CreateUnknownUser(string id) =>
|
|
||||||
new User(id, 0, "Unknown", null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
Author;Date;Content;Attachments;
|
|
||||||
{{~ for message in Model.Messages -}}
|
|
||||||
{{- }}"{{ message.Author.FullName }}";
|
|
||||||
|
|
||||||
{{- }}"{{ message.Timestamp | FormatDate }}";
|
|
||||||
|
|
||||||
{{- }}"{{ message.Content | FormatContent }}";
|
|
||||||
|
|
||||||
{{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}";
|
|
||||||
{{~ end -}}
|
|
||||||
|
Can't render this file because it contains an unexpected character in line 3 and column 9.
|
@@ -1,7 +0,0 @@
|
|||||||
{{
|
|
||||||
$SharedStyleSheet = include "Html.Shared.css"
|
|
||||||
$ThemeStyleSheet = include "Html.DarkTheme.css"
|
|
||||||
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
|
|
||||||
}}
|
|
||||||
|
|
||||||
{{ include "Html.Core.html" }}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{{
|
|
||||||
$SharedStyleSheet = include "Html.Shared.css"
|
|
||||||
$ThemeStyleSheet = include "Html.LightTheme.css"
|
|
||||||
StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet
|
|
||||||
}}
|
|
||||||
|
|
||||||
{{ include "Html.Core.html" }}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{{~ # Info ~}}
|
|
||||||
==============================================================
|
|
||||||
Guild: {{ Model.Guild.Name }}
|
|
||||||
Channel: {{ Model.Channel.Name }}
|
|
||||||
Topic: {{ Model.Channel.Topic }}
|
|
||||||
Messages: {{ Model.Messages | array.size | Format "N0" }}
|
|
||||||
Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model.From || Model.To }}->{{ end }}{{ if Model.To }} {{ Model.To | FormatDate }}{{ end }}
|
|
||||||
==============================================================
|
|
||||||
|
|
||||||
{{~ # Log ~}}
|
|
||||||
{{~ for message in Model.Messages ~}}
|
|
||||||
{{~ # Author name and timestamp ~}}
|
|
||||||
{{~ }}[{{ message.Timestamp | FormatDate }}] {{ message.Author.FullName }}
|
|
||||||
{{~ # Content ~}}
|
|
||||||
{{~ message.Content | FormatContent }}
|
|
||||||
{{~ # Attachments ~}}
|
|
||||||
{{~ for attachment in message.Attachments ~}}
|
|
||||||
{{~ attachment.Url }}
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ end ~}}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
using Scriban;
|
|
||||||
using Scriban.Parsing;
|
|
||||||
using Scriban.Runtime;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public partial class ExportService
|
|
||||||
{
|
|
||||||
private class TemplateLoader : ITemplateLoader
|
|
||||||
{
|
|
||||||
private const string ResourceRootNamespace = "DiscordChatExporter.Core.Resources.ExportTemplates";
|
|
||||||
|
|
||||||
public string GetPath(TemplateContext context, SourceSpan callerSpan, string templateName)
|
|
||||||
{
|
|
||||||
return $"{ResourceRootNamespace}.{templateName}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string GetPath(ExportFormat format)
|
|
||||||
{
|
|
||||||
return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
|
|
||||||
{
|
|
||||||
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Load(ExportFormat format)
|
|
||||||
{
|
|
||||||
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using DiscordChatExporter.Core.Internal;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
using Scriban.Runtime;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public partial class ExportService
|
|
||||||
{
|
|
||||||
private class TemplateModel
|
|
||||||
{
|
|
||||||
private readonly ExportFormat _format;
|
|
||||||
private readonly ChatLog _log;
|
|
||||||
private readonly string _dateFormat;
|
|
||||||
private readonly int _messageGroupLimit;
|
|
||||||
|
|
||||||
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat, int messageGroupLimit)
|
|
||||||
{
|
|
||||||
_format = format;
|
|
||||||
_log = log;
|
|
||||||
_dateFormat = dateFormat;
|
|
||||||
_messageGroupLimit = messageGroupLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
|
||||||
{
|
|
||||||
// Group adjacent messages by timestamp and author
|
|
||||||
var buffer = new List<Message>();
|
|
||||||
foreach (var message in messages)
|
|
||||||
{
|
|
||||||
var groupFirst = buffer.FirstOrDefault();
|
|
||||||
|
|
||||||
// Group break condition
|
|
||||||
var breakCondition =
|
|
||||||
groupFirst != null &&
|
|
||||||
(
|
|
||||||
message.Author.Id != groupFirst.Author.Id ||
|
|
||||||
(message.Timestamp - groupFirst.Timestamp).TotalHours > 1 ||
|
|
||||||
message.Timestamp.Hour != groupFirst.Timestamp.Hour ||
|
|
||||||
buffer.Count >= _messageGroupLimit
|
|
||||||
);
|
|
||||||
|
|
||||||
// If condition is true - flush buffer
|
|
||||||
if (breakCondition)
|
|
||||||
{
|
|
||||||
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
|
|
||||||
|
|
||||||
// Reset the buffer instead of clearing to avoid mutations on existing references
|
|
||||||
buffer = new List<Message>();
|
|
||||||
|
|
||||||
yield return group;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add message to buffer
|
|
||||||
buffer.Add(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add what's remaining in buffer
|
|
||||||
if (buffer.Any())
|
|
||||||
{
|
|
||||||
var groupFirst = buffer.First();
|
|
||||||
var group = new MessageGroup(groupFirst.Author, groupFirst.Timestamp, buffer);
|
|
||||||
|
|
||||||
yield return group;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private string HtmlEncode(string str) => WebUtility.HtmlEncode(str);
|
|
||||||
|
|
||||||
private string Format(IFormattable obj, string format) =>
|
|
||||||
obj.ToString(format, CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
|
|
||||||
|
|
||||||
private string FormatFileSize(long fileSize)
|
|
||||||
{
|
|
||||||
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
|
|
||||||
double size = fileSize;
|
|
||||||
var unit = 0;
|
|
||||||
|
|
||||||
while (size >= 1024)
|
|
||||||
{
|
|
||||||
size /= 1024;
|
|
||||||
++unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $"{size:0.#} {units[unit]}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatColor(Color color)
|
|
||||||
{
|
|
||||||
return $"{color.R},{color.G},{color.B},{color.A}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatContentPlainText(string content)
|
|
||||||
{
|
|
||||||
// New lines
|
|
||||||
content = content.Replace("\n", Environment.NewLine);
|
|
||||||
|
|
||||||
// User mentions (<@id> and <@!id>)
|
|
||||||
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedUserId in mentionedUserIds)
|
|
||||||
{
|
|
||||||
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
|
||||||
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel mentions (<#id>)
|
|
||||||
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedChannelId in mentionedChannelIds)
|
|
||||||
{
|
|
||||||
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
|
||||||
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role mentions (<@&id>)
|
|
||||||
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedRoleId in mentionedRoleIds)
|
|
||||||
{
|
|
||||||
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
|
||||||
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom emojis (<:name:id>)
|
|
||||||
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatContentHtml(string content, bool allowLinks = false)
|
|
||||||
{
|
|
||||||
// HTML-encode content
|
|
||||||
content = HtmlEncode(content);
|
|
||||||
|
|
||||||
// Encode multiline codeblocks (```text```)
|
|
||||||
content = Regex.Replace(content,
|
|
||||||
@"```+(?:[^`]*?\n)?([^`]+)\n?```+",
|
|
||||||
m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM");
|
|
||||||
|
|
||||||
// Encode inline codeblocks (`text`)
|
|
||||||
content = Regex.Replace(content,
|
|
||||||
@"`([^`]+)`",
|
|
||||||
m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI");
|
|
||||||
|
|
||||||
// Encode links
|
|
||||||
if (allowLinks)
|
|
||||||
{
|
|
||||||
content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)",
|
|
||||||
m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encode URLs
|
|
||||||
content = Regex.Replace(content,
|
|
||||||
@"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))",
|
|
||||||
m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU");
|
|
||||||
|
|
||||||
// Process bold (**text**)
|
|
||||||
content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "<b>$2</b>");
|
|
||||||
|
|
||||||
// Process underline (__text__)
|
|
||||||
content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "<u>$2</u>");
|
|
||||||
|
|
||||||
// Process italic (*text* or _text_)
|
|
||||||
content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "<i>$2</i>");
|
|
||||||
|
|
||||||
// Process strike through (~~text~~)
|
|
||||||
content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "<s>$2</s>");
|
|
||||||
|
|
||||||
// Decode and process multiline codeblocks
|
|
||||||
content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
|
|
||||||
m => $"<div class=\"pre pre--multiline\">{m.Groups[1].Value.Base64Decode()}</div>");
|
|
||||||
|
|
||||||
// Decode and process inline codeblocks
|
|
||||||
content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
|
|
||||||
m => $"<span class=\"pre pre--inline\">{m.Groups[1].Value.Base64Decode()}</span>");
|
|
||||||
|
|
||||||
// Decode and process links
|
|
||||||
if (allowLinks)
|
|
||||||
{
|
|
||||||
content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
|
|
||||||
m => $"<a href=\"{m.Groups[2].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode and process URLs
|
|
||||||
content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
|
|
||||||
m => $"<a href=\"{m.Groups[1].Value.Base64Decode()}\">{m.Groups[1].Value.Base64Decode()}</a>");
|
|
||||||
|
|
||||||
// Process new lines
|
|
||||||
content = content.Replace("\n", "<br />");
|
|
||||||
|
|
||||||
// Meta mentions (@everyone)
|
|
||||||
content = content.Replace("@everyone", "<span class=\"mention\">@everyone</span>");
|
|
||||||
|
|
||||||
// Meta mentions (@here)
|
|
||||||
content = content.Replace("@here", "<span class=\"mention\">@here</span>");
|
|
||||||
|
|
||||||
// User mentions (<@id> and <@!id>)
|
|
||||||
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedUserId in mentionedUserIds)
|
|
||||||
{
|
|
||||||
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
|
||||||
content = Regex.Replace(content, $"<@!?{mentionedUserId}>",
|
|
||||||
$"<span class=\"mention\" title=\"{HtmlEncode(mentionedUser.FullName)}\">" +
|
|
||||||
$"@{HtmlEncode(mentionedUser.Name)}" +
|
|
||||||
"</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel mentions (<#id>)
|
|
||||||
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedChannelId in mentionedChannelIds)
|
|
||||||
{
|
|
||||||
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
|
||||||
content = content.Replace($"<#{mentionedChannelId}>",
|
|
||||||
"<span class=\"mention\">" +
|
|
||||||
$"#{HtmlEncode(mentionedChannel.Name)}" +
|
|
||||||
"</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role mentions (<@&id>)
|
|
||||||
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedRoleId in mentionedRoleIds)
|
|
||||||
{
|
|
||||||
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
|
||||||
content = content.Replace($"<@&{mentionedRoleId}>",
|
|
||||||
"<span class=\"mention\">" +
|
|
||||||
$"@{HtmlEncode(mentionedRole.Name)}" +
|
|
||||||
"</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom emojis (<:name:id>)
|
|
||||||
var isJumboable = Regex.Replace(content, "<(:.*?:)(\\d*)>", "").IsBlank();
|
|
||||||
var emojiClass = isJumboable ? "emoji emoji--large" : "emoji";
|
|
||||||
content = Regex.Replace(content, "<(:.*?:)(\\d*)>",
|
|
||||||
$"<img class=\"{emojiClass}\" title=\"$1\" src=\"https://cdn.discordapp.com/emojis/$2.png\" />");
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatContentCsv(string content)
|
|
||||||
{
|
|
||||||
// Escape quotes
|
|
||||||
content = content.Replace("\"", "\"\"");
|
|
||||||
|
|
||||||
// Escape commas and semicolons
|
|
||||||
if (content.Contains(",") || content.Contains(";"))
|
|
||||||
content = $"\"{content}\"";
|
|
||||||
|
|
||||||
// User mentions (<@id> and <@!id>)
|
|
||||||
var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedUserId in mentionedUserIds)
|
|
||||||
{
|
|
||||||
var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
|
|
||||||
content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Channel mentions (<#id>)
|
|
||||||
var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedChannelId in mentionedChannelIds)
|
|
||||||
{
|
|
||||||
var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId);
|
|
||||||
content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Role mentions (<@&id>)
|
|
||||||
var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>")
|
|
||||||
.Cast<Match>()
|
|
||||||
.Select(m => m.Groups[1].Value)
|
|
||||||
.ExceptBlank()
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
foreach (var mentionedRoleId in mentionedRoleIds)
|
|
||||||
{
|
|
||||||
var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId);
|
|
||||||
content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom emojis (<:name:id>)
|
|
||||||
content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1");
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatContent(string content, bool allowLinks = false)
|
|
||||||
{
|
|
||||||
if (_format == ExportFormat.PlainText)
|
|
||||||
return FormatContentPlainText(content);
|
|
||||||
|
|
||||||
if (_format == ExportFormat.HtmlDark)
|
|
||||||
return FormatContentHtml(content, allowLinks);
|
|
||||||
|
|
||||||
if (_format == ExportFormat.HtmlLight)
|
|
||||||
return FormatContentHtml(content, allowLinks);
|
|
||||||
|
|
||||||
if (_format == ExportFormat.Csv)
|
|
||||||
return FormatContentCsv(content);
|
|
||||||
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(_format));
|
|
||||||
}
|
|
||||||
|
|
||||||
public ScriptObject GetScriptObject()
|
|
||||||
{
|
|
||||||
// Create instance
|
|
||||||
var scriptObject = new ScriptObject();
|
|
||||||
|
|
||||||
// Import chat log
|
|
||||||
scriptObject.SetValue("Model", _log, true);
|
|
||||||
|
|
||||||
// Import functions
|
|
||||||
scriptObject.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
|
|
||||||
scriptObject.Import(nameof(Format), new Func<IFormattable, string, string>(Format));
|
|
||||||
scriptObject.Import(nameof(FormatDate), new Func<DateTime, string>(FormatDate));
|
|
||||||
scriptObject.Import(nameof(FormatFileSize), new Func<long, string>(FormatFileSize));
|
|
||||||
scriptObject.Import(nameof(FormatColor), new Func<Color, string>(FormatColor));
|
|
||||||
scriptObject.Import(nameof(FormatContent), new Func<string, bool, string>(FormatContent));
|
|
||||||
|
|
||||||
return scriptObject;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
using Scriban;
|
|
||||||
using Scriban.Runtime;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public partial class ExportService : IExportService
|
|
||||||
{
|
|
||||||
private readonly ISettingsService _settingsService;
|
|
||||||
|
|
||||||
public ExportService(ISettingsService settingsService)
|
|
||||||
{
|
|
||||||
_settingsService = settingsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExportChatLogSingle(ChatLog chatLog, string filePath, ExportFormat format)
|
|
||||||
{
|
|
||||||
// Create template loader
|
|
||||||
var loader = new TemplateLoader();
|
|
||||||
|
|
||||||
// Get template
|
|
||||||
var templateCode = loader.Load(format);
|
|
||||||
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 templateModel = new TemplateModel(format, chatLog,
|
|
||||||
_settingsService.DateFormat, _settingsService.MessageGroupLimit);
|
|
||||||
|
|
||||||
context.PushGlobal(templateModel.GetScriptObject());
|
|
||||||
|
|
||||||
// Create directory
|
|
||||||
var dirPath = Path.GetDirectoryName(filePath);
|
|
||||||
if (dirPath.IsNotBlank())
|
|
||||||
Directory.CreateDirectory(dirPath);
|
|
||||||
|
|
||||||
// Render output
|
|
||||||
using (var output = File.CreateText(filePath))
|
|
||||||
{
|
|
||||||
// Configure output
|
|
||||||
context.PushOutput(new TextWriterOutput(output));
|
|
||||||
|
|
||||||
// Render output
|
|
||||||
context.Evaluate(template.Page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ExportChatLogPartitions(IReadOnlyList<ChatLog> partitions, string filePath, ExportFormat format)
|
|
||||||
{
|
|
||||||
// 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}{fileExt}";
|
|
||||||
|
|
||||||
// Compose full file path
|
|
||||||
if (dirPath.IsNotBlank())
|
|
||||||
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
|
|
||||||
|
|
||||||
// Export
|
|
||||||
ExportChatLogSingle(partition, partitionFilePath, format);
|
|
||||||
|
|
||||||
// Increment partition number
|
|
||||||
partitionNumber++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
|
|
||||||
int? partitionLimit = null)
|
|
||||||
{
|
|
||||||
// If partitioning is disabled or there are fewer messages in chat log than the limit - process it without partitioning
|
|
||||||
if (partitionLimit == null || chatLog.Messages.Count <= partitionLimit)
|
|
||||||
{
|
|
||||||
ExportChatLogSingle(chatLog, filePath, format);
|
|
||||||
}
|
|
||||||
// Otherwise split into partitions and export separately
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var partitions = chatLog.SplitIntoPartitions(partitionLimit.Value);
|
|
||||||
ExportChatLogPartitions(partitions, filePath, format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public interface IDataService
|
|
||||||
{
|
|
||||||
Task<Guild> GetGuildAsync(AuthToken token, string guildId);
|
|
||||||
|
|
||||||
Task<Channel> GetChannelAsync(AuthToken token, string channelId);
|
|
||||||
|
|
||||||
Task<IReadOnlyList<Guild>> GetUserGuildsAsync(AuthToken token);
|
|
||||||
|
|
||||||
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(AuthToken token);
|
|
||||||
|
|
||||||
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(AuthToken token, string guildId);
|
|
||||||
|
|
||||||
Task<IReadOnlyList<Role>> GetGuildRolesAsync(AuthToken token, string guildId);
|
|
||||||
|
|
||||||
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(AuthToken token, string channelId,
|
|
||||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
|
||||||
|
|
||||||
Task<Mentionables> GetMentionablesAsync(AuthToken token, string guildId,
|
|
||||||
IEnumerable<Message> messages);
|
|
||||||
|
|
||||||
Task<ChatLog> GetChatLogAsync(AuthToken token, Guild guild, Channel channel,
|
|
||||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
|
||||||
|
|
||||||
Task<ChatLog> GetChatLogAsync(AuthToken token, string channelId,
|
|
||||||
DateTime? from = null, DateTime? to = null, IProgress<double> progress = null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public interface IExportService
|
|
||||||
{
|
|
||||||
void ExportChatLog(ChatLog chatLog, string filePath, ExportFormat format,
|
|
||||||
int? partitionLimit = null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public interface ISettingsService
|
|
||||||
{
|
|
||||||
bool IsAutoUpdateEnabled { get; set; }
|
|
||||||
|
|
||||||
string DateFormat { get; set; }
|
|
||||||
int MessageGroupLimit { get; set; }
|
|
||||||
|
|
||||||
AuthToken LastToken { get; set; }
|
|
||||||
ExportFormat LastExportFormat { get; set; }
|
|
||||||
int? LastPartitionLimit { get; set; }
|
|
||||||
|
|
||||||
void Load();
|
|
||||||
void Save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public interface IUpdateService
|
|
||||||
{
|
|
||||||
bool NeedRestart { get; set; }
|
|
||||||
|
|
||||||
Task<Version> CheckPrepareUpdateAsync();
|
|
||||||
|
|
||||||
void FinalizeUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Onova;
|
|
||||||
using Onova.Services;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public class UpdateService : IUpdateService
|
|
||||||
{
|
|
||||||
private readonly ISettingsService _settingsService;
|
|
||||||
private readonly IUpdateManager _manager;
|
|
||||||
|
|
||||||
private Version _updateVersion;
|
|
||||||
private bool _updateFinalized;
|
|
||||||
|
|
||||||
public bool NeedRestart { get; set; }
|
|
||||||
|
|
||||||
public UpdateService(ISettingsService settingsService)
|
|
||||||
{
|
|
||||||
_settingsService = settingsService;
|
|
||||||
|
|
||||||
_manager = new UpdateManager(
|
|
||||||
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
|
|
||||||
new ZipPackageExtractor());
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Version> CheckPrepareUpdateAsync()
|
|
||||||
{
|
|
||||||
// If auto-update is disabled - don't check for updates
|
|
||||||
if (!_settingsService.IsAutoUpdateEnabled)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Cleanup leftover files
|
|
||||||
_manager.Cleanup();
|
|
||||||
|
|
||||||
// Check for updates
|
|
||||||
var check = await _manager.CheckForUpdatesAsync();
|
|
||||||
if (!check.CanUpdate)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
// Prepare the update
|
|
||||||
if (!_manager.IsUpdatePrepared(check.LastVersion))
|
|
||||||
await _manager.PrepareUpdateAsync(check.LastVersion);
|
|
||||||
|
|
||||||
return _updateVersion = check.LastVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void FinalizeUpdate()
|
|
||||||
{
|
|
||||||
// Check if an update is pending
|
|
||||||
if (_updateVersion == null)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Check if the update has already been finalized
|
|
||||||
if (_updateFinalized)
|
|
||||||
return;
|
|
||||||
|
|
||||||
// Launch the updater
|
|
||||||
_manager.LaunchUpdater(_updateVersion, NeedRestart);
|
|
||||||
_updateFinalized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user