mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-05-22 10:55:15 -04:00
Improve performance (#162)
This commit is contained in:
parent
359278afec
commit
4bfb2ec7fd
86 changed files with 1242 additions and 900 deletions
|
@ -11,7 +11,7 @@ namespace DiscordChatExporter.Cli
|
||||||
{
|
{
|
||||||
var builder = new StyletIoCBuilder();
|
var builder = new StyletIoCBuilder();
|
||||||
|
|
||||||
// Autobind services in the .Core assembly
|
// Autobind the .Services assembly
|
||||||
builder.Autobind(typeof(DataService).Assembly);
|
builder.Autobind(typeof(DataService).Assembly);
|
||||||
|
|
||||||
// Bind settings as singleton
|
// Bind settings as singleton
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
|
<TargetFrameworks>net46;netcoreapp2.1</TargetFrameworks>
|
||||||
<Version>2.11</Version>
|
<Version>2.11</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CommandLineParser" Version="2.3.0" />
|
<PackageReference Include="CommandLineParser" Version="2.3.0" />
|
||||||
<PackageReference Include="Stylet" Version="1.1.22" />
|
<PackageReference Include="Stylet" Version="1.1.22" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" />
|
||||||
</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>
|
|
@ -3,8 +3,8 @@ using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Cli.Internal;
|
using DiscordChatExporter.Cli.Internal;
|
||||||
using DiscordChatExporter.Cli.Verbs.Options;
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
using DiscordChatExporter.Core.Helpers;
|
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Verbs
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
@ -24,7 +24,7 @@ namespace DiscordChatExporter.Cli.Verbs
|
||||||
var exportService = Container.Instance.Get<ExportService>();
|
var exportService = Container.Instance.Get<ExportService>();
|
||||||
|
|
||||||
// Configure settings
|
// Configure settings
|
||||||
if (Options.DateFormat.IsNotBlank())
|
if (!Options.DateFormat.EmptyIfNull().IsWhiteSpace())
|
||||||
settingsService.DateFormat = Options.DateFormat;
|
settingsService.DateFormat = Options.DateFormat;
|
||||||
|
|
||||||
// Track progress
|
// Track progress
|
||||||
|
@ -37,7 +37,7 @@ namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
|
||||||
// Generate file path if not set or is a directory
|
// Generate file path if not set or is a directory
|
||||||
var filePath = Options.OutputPath;
|
var filePath = Options.OutputPath;
|
||||||
if (filePath.IsBlank() || ExportHelper.IsDirectoryPath(filePath))
|
if (filePath.EmptyIfNull().IsWhiteSpace() || ExportHelper.IsDirectoryPath(filePath))
|
||||||
{
|
{
|
||||||
// Generate default file name
|
// Generate default file name
|
||||||
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
|
var fileName = ExportHelper.GetDefaultExportFileName(Options.ExportFormat, chatLog.Guild,
|
||||||
|
|
|
@ -5,9 +5,9 @@ using System.Net;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Cli.Internal;
|
using DiscordChatExporter.Cli.Internal;
|
||||||
using DiscordChatExporter.Cli.Verbs.Options;
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
|
||||||
using DiscordChatExporter.Core.Helpers;
|
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Verbs
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
@ -27,7 +27,7 @@ namespace DiscordChatExporter.Cli.Verbs
|
||||||
var exportService = Container.Instance.Get<ExportService>();
|
var exportService = Container.Instance.Get<ExportService>();
|
||||||
|
|
||||||
// Configure settings
|
// Configure settings
|
||||||
if (Options.DateFormat.IsNotBlank())
|
if (!Options.DateFormat.EmptyIfNull().IsWhiteSpace())
|
||||||
settingsService.DateFormat = Options.DateFormat;
|
settingsService.DateFormat = Options.DateFormat;
|
||||||
|
|
||||||
// Get channels
|
// Get channels
|
||||||
|
|
|
@ -5,10 +5,10 @@ using System.Net;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Cli.Internal;
|
using DiscordChatExporter.Cli.Internal;
|
||||||
using DiscordChatExporter.Cli.Verbs.Options;
|
using DiscordChatExporter.Cli.Verbs.Options;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
|
||||||
using DiscordChatExporter.Core.Helpers;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Verbs
|
namespace DiscordChatExporter.Cli.Verbs
|
||||||
|
@ -28,7 +28,7 @@ namespace DiscordChatExporter.Cli.Verbs
|
||||||
var exportService = Container.Instance.Get<ExportService>();
|
var exportService = Container.Instance.Get<ExportService>();
|
||||||
|
|
||||||
// Configure settings
|
// Configure settings
|
||||||
if (Options.DateFormat.IsNotBlank())
|
if (!Options.DateFormat.EmptyIfNull().IsWhiteSpace())
|
||||||
settingsService.DateFormat = Options.DateFormat;
|
settingsService.DateFormat = Options.DateFormat;
|
||||||
|
|
||||||
// Get channels
|
// Get channels
|
||||||
|
|
|
@ -5,8 +5,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Sprache" Version="2.2.0" />
|
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.0" />
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
|
@ -0,0 +1,46 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
|
{
|
||||||
|
internal class AggregateMatcher<T> : IMatcher<T>
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<IMatcher<T>> _matchers;
|
||||||
|
|
||||||
|
public AggregateMatcher(IReadOnlyList<IMatcher<T>> matchers)
|
||||||
|
{
|
||||||
|
_matchers = matchers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AggregateMatcher(params IMatcher<T>[] matchers)
|
||||||
|
: this((IReadOnlyList<IMatcher<T>>)matchers)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public ParsedMatch<T> Match(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
DiscordChatExporter.Core.Markdown/Internal/Extensions.cs
Normal file
50
DiscordChatExporter.Core.Markdown/Internal/Extensions.cs
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,178 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Sprache;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Internal
|
|
||||||
{
|
|
||||||
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
|
|
||||||
internal static class Grammar
|
|
||||||
{
|
|
||||||
/* Formatting */
|
|
||||||
|
|
||||||
// Capture until the earliest double asterisk not followed by an asterisk
|
|
||||||
private static readonly Parser<Node> BoldFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "**", TextFormatting.Bold, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Capture until the earliest single asterisk not preceded or followed by an asterisk
|
|
||||||
// Can't have whitespace right after opening or right before closing asterisk
|
|
||||||
private static readonly Parser<Node> ItalicFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "*", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Can't have underscores inside
|
|
||||||
// Can't have word characters right after closing underscore
|
|
||||||
private static readonly Parser<Node> ItalicAltFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("_([^_]+?)_(?!\\w)", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "_", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Treated as a separate entity for simplicity
|
|
||||||
// Capture until the earliest triple asterisk not preceded or followed by an asterisk
|
|
||||||
private static readonly Parser<Node> ItalicBoldFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("\\*(\\*\\*(?:.+?)\\*\\*)\\*(?!\\*)", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "*", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Capture until the earliest double underscore not followed by an underscore
|
|
||||||
private static readonly Parser<Node> UnderlineFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("__(.+?)__(?!_)", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "__", TextFormatting.Underline, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Treated as a separate entity for simplicity
|
|
||||||
// Capture until the earliest triple underscore not preceded or followed by an underscore
|
|
||||||
private static readonly Parser<Node> ItalicUnderlineFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("_(__(?:.+?)__)_(?!_)", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "_", TextFormatting.Italic, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Strikethrough is safe
|
|
||||||
private static readonly Parser<Node> StrikethroughFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("~~(.+?)~~", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "~~", TextFormatting.Strikethrough, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Spoiler is safe
|
|
||||||
private static readonly Parser<Node> SpoilerFormattedNode =
|
|
||||||
Parse.RegexMatch(new Regex("\\|\\|(.+?)\\|\\|", RegexOptions.Singleline))
|
|
||||||
.Select(m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, BuildTree(m.Groups[1].Value)));
|
|
||||||
|
|
||||||
// Combinator, order matters
|
|
||||||
private static readonly Parser<Node> AnyFormattedNode =
|
|
||||||
ItalicBoldFormattedNode.Or(ItalicUnderlineFormattedNode)
|
|
||||||
.Or(BoldFormattedNode).Or(ItalicFormattedNode)
|
|
||||||
.Or(UnderlineFormattedNode).Or(ItalicAltFormattedNode)
|
|
||||||
.Or(StrikethroughFormattedNode).Or(SpoilerFormattedNode);
|
|
||||||
|
|
||||||
/* Code blocks */
|
|
||||||
|
|
||||||
// Can't have backticks inside and surrounding whitespace is trimmed
|
|
||||||
private static readonly Parser<Node> InlineCodeBlockNode =
|
|
||||||
Parse.RegexMatch(new Regex("`\\s*([^`]+?)\\s*`", RegexOptions.Singleline))
|
|
||||||
.Select(m => new InlineCodeBlockNode(m.Value, m.Groups[1].Value));
|
|
||||||
|
|
||||||
// The first word is a language identifier if it's the only word followed by a newline, the rest is code
|
|
||||||
private static readonly Parser<Node> MultilineCodeBlockNode =
|
|
||||||
Parse.RegexMatch(new Regex("```(?:(\\w*?)?(?:\\s*?\\n))?(.+?)```", RegexOptions.Singleline))
|
|
||||||
.Select(m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value));
|
|
||||||
|
|
||||||
// Combinator, order matters
|
|
||||||
private static readonly Parser<Node> AnyCodeBlockNode = MultilineCodeBlockNode.Or(InlineCodeBlockNode);
|
|
||||||
|
|
||||||
/* Mentions */
|
|
||||||
|
|
||||||
// @everyone or @here
|
|
||||||
private static readonly Parser<Node> MetaMentionNode = Parse.RegexMatch("@(everyone|here)")
|
|
||||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Meta));
|
|
||||||
|
|
||||||
// <@123456> or <@!123456>
|
|
||||||
private static readonly Parser<Node> UserMentionNode = Parse.RegexMatch("<@!?(\\d+)>")
|
|
||||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.User));
|
|
||||||
|
|
||||||
// <#123456>
|
|
||||||
private static readonly Parser<Node> ChannelMentionNode = Parse.RegexMatch("<#(\\d+)>")
|
|
||||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Channel));
|
|
||||||
|
|
||||||
// <@&123456>
|
|
||||||
private static readonly Parser<Node> RoleMentionNode = Parse.RegexMatch("<@&(\\d+)>")
|
|
||||||
.Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Role));
|
|
||||||
|
|
||||||
// Combinator, order matters
|
|
||||||
private static readonly Parser<Node> AnyMentionNode =
|
|
||||||
MetaMentionNode.Or(UserMentionNode).Or(ChannelMentionNode).Or(RoleMentionNode);
|
|
||||||
|
|
||||||
/* Emojis */
|
|
||||||
|
|
||||||
// Matches all standard unicode emojis
|
|
||||||
private static readonly Parser<Node> StandardEmojiNode = Parse.RegexMatch(
|
|
||||||
"([\\u2700-\\u27bf]|" +
|
|
||||||
"(?:\\ud83c[\\udde6-\\uddff]){2}|" +
|
|
||||||
"[\\ud800-\\udbff][\\udc00-\\udfff]|" +
|
|
||||||
"[\\u0023-\\u0039]\\u20e3|" +
|
|
||||||
"\\u3299|\\u3297|\\u303d|\\u3030|\\u24c2|\\ud83c[\\udd70-\\udd71]|\\ud83c[\\udd7e-\\udd7f]|\\ud83c\\udd8e|\\ud83c[\\udd91-\\udd9a]|\\ud83c[\\udde6-\\uddff]|" +
|
|
||||||
"\\ud83c[\\ude01-\\ude02]|\\ud83c\\ude1a|\\ud83c\\ude2f|\\ud83c[\\ude32-\\ude3a]|\\ud83c[\\ude50-\\ude51]|\\u203c|\\u2049|[\\u25aa-\\u25ab]|" +
|
|
||||||
"\\u25b6|\\u25c0|[\\u25fb-\\u25fe]|\\u00a9|\\u00ae|\\u2122|\\u2139|\\ud83c\\udc04|[\\u2600-\\u26FF]|\\u2b05|\\u2b06|\\u2b07|\\u2b1b|\\u2b1c|\\u2b50|" +
|
|
||||||
"\\u2b55|\\u231a|\\u231b|\\u2328|\\u23cf|[\\u23e9-\\u23f3]|[\\u23f8-\\u23fa]|\\ud83c\\udccf|\\u2934|\\u2935|[\\u2190-\\u21ff])")
|
|
||||||
.Select(m => new EmojiNode(m.Value, m.Groups[1].Value));
|
|
||||||
|
|
||||||
// <:lul:123456> or <a:lul:123456>
|
|
||||||
private static readonly Parser<Node> CustomEmojiNode = Parse.RegexMatch("<(a)?:(.+?):(\\d+)>")
|
|
||||||
.Select(m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, m.Groups[1].Value.IsNotBlank()));
|
|
||||||
|
|
||||||
// Combinator, order matters
|
|
||||||
private static readonly Parser<Node> AnyEmojiNode = StandardEmojiNode.Or(CustomEmojiNode);
|
|
||||||
|
|
||||||
/* Links */
|
|
||||||
|
|
||||||
// [title](link)
|
|
||||||
private static readonly Parser<Node> TitledLinkNode = Parse.RegexMatch("\\[(.+?)\\]\\((.+?)\\)")
|
|
||||||
.Select(m => new LinkNode(m.Value, m.Groups[2].Value, m.Groups[1].Value));
|
|
||||||
|
|
||||||
// Starts with http:// or https://, stops at the last non-whitespace character followed by whitespace or punctuation character
|
|
||||||
private static readonly Parser<Node> AutoLinkNode = Parse.RegexMatch("(https?://\\S*[^\\.,:;\"\'\\s])")
|
|
||||||
.Select(m => new LinkNode(m.Value, m.Groups[1].Value));
|
|
||||||
|
|
||||||
// Autolink surrounded by angular brackets
|
|
||||||
private static readonly Parser<Node> HiddenLinkNode = Parse.RegexMatch("<(https?://\\S*[^\\.,:;\"\'\\s])>")
|
|
||||||
.Select(m => new LinkNode(m.Value, m.Groups[1].Value));
|
|
||||||
|
|
||||||
// Combinator, order matters
|
|
||||||
private static readonly Parser<Node> AnyLinkNode = TitledLinkNode.Or(HiddenLinkNode).Or(AutoLinkNode);
|
|
||||||
|
|
||||||
/* Text */
|
|
||||||
|
|
||||||
// Shrug is an exception and needs to be exempt from formatting
|
|
||||||
private static readonly Parser<Node> ShrugTextNode =
|
|
||||||
Parse.String("¯\\_(ツ)_/¯").Text().Select(s => new TextNode(s));
|
|
||||||
|
|
||||||
// Backslash escapes any following unicode surrogate pair
|
|
||||||
private static readonly Parser<Node> EscapedSurrogateTextNode =
|
|
||||||
from slash in Parse.Char('\\')
|
|
||||||
from high in Parse.AnyChar.Where(char.IsHighSurrogate)
|
|
||||||
from low in Parse.AnyChar
|
|
||||||
let lexeme = $"{slash}{high}{low}"
|
|
||||||
let text = $"{high}{low}"
|
|
||||||
select new TextNode(lexeme, text);
|
|
||||||
|
|
||||||
// Backslash escapes any following non-whitespace character except for digits and latin letters
|
|
||||||
private static readonly Parser<Node> EscapedTextNode =
|
|
||||||
Parse.RegexMatch("\\\\([^a-zA-Z0-9\\s])").Select(m => new TextNode(m.Value, m.Groups[1].Value));
|
|
||||||
|
|
||||||
// Combinator, order matters
|
|
||||||
private static readonly Parser<Node> AnyTextNode = ShrugTextNode.Or(EscapedSurrogateTextNode).Or(EscapedTextNode);
|
|
||||||
|
|
||||||
/* Aggregator and fallback */
|
|
||||||
|
|
||||||
// Any node recognized by above patterns
|
|
||||||
private static readonly Parser<Node> AnyRecognizedNode = AnyFormattedNode.Or(AnyCodeBlockNode)
|
|
||||||
.Or(AnyMentionNode).Or(AnyEmojiNode).Or(AnyLinkNode).Or(AnyTextNode);
|
|
||||||
|
|
||||||
// Any node not recognized by above patterns (treated as plain text)
|
|
||||||
private static readonly Parser<Node> FallbackNode =
|
|
||||||
Parse.AnyChar.Except(AnyRecognizedNode).AtLeastOnce().Text().Select(s => new TextNode(s));
|
|
||||||
|
|
||||||
// Any node
|
|
||||||
private static readonly Parser<Node> AnyNode = AnyRecognizedNode.Or(FallbackNode);
|
|
||||||
|
|
||||||
// Entry point
|
|
||||||
public static IReadOnlyList<Node> BuildTree(string input) => AnyNode.Many().Parse(input).ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
7
DiscordChatExporter.Core.Markdown/Internal/IMatcher.cs
Normal file
7
DiscordChatExporter.Core.Markdown/Internal/IMatcher.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace DiscordChatExporter.Core.Markdown.Internal
|
||||||
|
{
|
||||||
|
internal interface IMatcher<T>
|
||||||
|
{
|
||||||
|
ParsedMatch<T> Match(string input, int startIndex, int length);
|
||||||
|
}
|
||||||
|
}
|
18
DiscordChatExporter.Core.Markdown/Internal/ParsedMatch.cs
Normal file
18
DiscordChatExporter.Core.Markdown/Internal/ParsedMatch.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
23
DiscordChatExporter.Core.Markdown/Internal/RegexMatcher.cs
Normal file
23
DiscordChatExporter.Core.Markdown/Internal/RegexMatcher.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
29
DiscordChatExporter.Core.Markdown/Internal/StringMatcher.cs
Normal file
29
DiscordChatExporter.Core.Markdown/Internal/StringMatcher.cs
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,187 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Markdown.Internal;
|
using DiscordChatExporter.Core.Markdown.Internal;
|
||||||
|
using DiscordChatExporter.Core.Markdown.Nodes;
|
||||||
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown
|
||||||
{
|
{
|
||||||
|
// The following parsing logic is meant to replicate Discord's markdown grammar as close as possible
|
||||||
public static class MarkdownParser
|
public static class MarkdownParser
|
||||||
{
|
{
|
||||||
public static IReadOnlyList<Node> Parse(string input) => Grammar.BuildTree(input);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,12 +0,0 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
|
||||||
{
|
|
||||||
public abstract class Node
|
|
||||||
{
|
|
||||||
public string Lexeme { get; }
|
|
||||||
|
|
||||||
protected Node(string lexeme)
|
|
||||||
{
|
|
||||||
Lexeme = lexeme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,6 +1,4 @@
|
||||||
using Tyrrrz.Extensions;
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
|
||||||
{
|
{
|
||||||
public class EmojiNode : Node
|
public class EmojiNode : Node
|
||||||
{
|
{
|
||||||
|
@ -10,18 +8,18 @@ namespace DiscordChatExporter.Core.Markdown
|
||||||
|
|
||||||
public bool IsAnimated { get; }
|
public bool IsAnimated { get; }
|
||||||
|
|
||||||
public bool IsCustomEmoji => Id.IsNotBlank();
|
public bool IsCustomEmoji => Id != null;
|
||||||
|
|
||||||
public EmojiNode(string lexeme, string id, string name, bool isAnimated)
|
public EmojiNode(string source, string id, string name, bool isAnimated)
|
||||||
: base(lexeme)
|
: base(source)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
IsAnimated = isAnimated;
|
IsAnimated = isAnimated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public EmojiNode(string lexeme, string name)
|
public EmojiNode(string source, string name)
|
||||||
: this(lexeme, null, name, false)
|
: this(source, null, name, false)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public class FormattedNode : Node
|
public class FormattedNode : Node
|
||||||
{
|
{
|
||||||
|
@ -10,8 +10,8 @@ namespace DiscordChatExporter.Core.Markdown
|
||||||
|
|
||||||
public IReadOnlyList<Node> Children { get; }
|
public IReadOnlyList<Node> Children { get; }
|
||||||
|
|
||||||
public FormattedNode(string lexeme, string token, TextFormatting formatting, IReadOnlyList<Node> children)
|
public FormattedNode(string source, string token, TextFormatting formatting, IReadOnlyList<Node> children)
|
||||||
: base(lexeme)
|
: base(source)
|
||||||
{
|
{
|
||||||
Token = token;
|
Token = token;
|
||||||
Formatting = formatting;
|
Formatting = formatting;
|
|
@ -1,11 +1,11 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public class InlineCodeBlockNode : Node
|
public class InlineCodeBlockNode : Node
|
||||||
{
|
{
|
||||||
public string Code { get; }
|
public string Code { get; }
|
||||||
|
|
||||||
public InlineCodeBlockNode(string lexeme, string code)
|
public InlineCodeBlockNode(string source, string code)
|
||||||
: base(lexeme)
|
: base(source)
|
||||||
{
|
{
|
||||||
Code = code;
|
Code = code;
|
||||||
}
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public class LinkNode : Node
|
public class LinkNode : Node
|
||||||
{
|
{
|
||||||
|
@ -6,14 +6,14 @@
|
||||||
|
|
||||||
public string Title { get; }
|
public string Title { get; }
|
||||||
|
|
||||||
public LinkNode(string lexeme, string url, string title)
|
public LinkNode(string source, string url, string title)
|
||||||
: base(lexeme)
|
: base(source)
|
||||||
{
|
{
|
||||||
Url = url;
|
Url = url;
|
||||||
Title = title;
|
Title = title;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LinkNode(string lexeme, string url) : this(lexeme, url, url)
|
public LinkNode(string source, string url) : this(source, url, url)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public class MentionNode : Node
|
public class MentionNode : Node
|
||||||
{
|
{
|
||||||
|
@ -6,8 +6,8 @@
|
||||||
|
|
||||||
public MentionType Type { get; }
|
public MentionType Type { get; }
|
||||||
|
|
||||||
public MentionNode(string lexeme, string id, MentionType type)
|
public MentionNode(string source, string id, MentionType type)
|
||||||
: base(lexeme)
|
: base(source)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Type = type;
|
Type = type;
|
|
@ -1,4 +1,4 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public enum MentionType
|
public enum MentionType
|
||||||
{
|
{
|
|
@ -1,4 +1,4 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public class MultilineCodeBlockNode : Node
|
public class MultilineCodeBlockNode : Node
|
||||||
{
|
{
|
||||||
|
@ -6,8 +6,8 @@
|
||||||
|
|
||||||
public string Code { get; }
|
public string Code { get; }
|
||||||
|
|
||||||
public MultilineCodeBlockNode(string lexeme, string language, string code)
|
public MultilineCodeBlockNode(string source, string language, string code)
|
||||||
: base(lexeme)
|
: base(source)
|
||||||
{
|
{
|
||||||
Language = language;
|
Language = language;
|
||||||
Code = code;
|
Code = code;
|
12
DiscordChatExporter.Core.Markdown/Nodes/Node.cs
Normal file
12
DiscordChatExporter.Core.Markdown/Nodes/Node.cs
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
|
{
|
||||||
|
public abstract class Node
|
||||||
|
{
|
||||||
|
public string Source { get; }
|
||||||
|
|
||||||
|
protected Node(string source)
|
||||||
|
{
|
||||||
|
Source = source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public enum TextFormatting
|
public enum TextFormatting
|
||||||
{
|
{
|
|
@ -1,11 +1,11 @@
|
||||||
namespace DiscordChatExporter.Core.Markdown
|
namespace DiscordChatExporter.Core.Markdown.Nodes
|
||||||
{
|
{
|
||||||
public class TextNode : Node
|
public class TextNode : Node
|
||||||
{
|
{
|
||||||
public string Text { get; }
|
public string Text { get; }
|
||||||
|
|
||||||
public TextNode(string lexeme, string text)
|
public TextNode(string source, string text)
|
||||||
: base(lexeme)
|
: base(source)
|
||||||
{
|
{
|
||||||
Text = text;
|
Text = text;
|
||||||
}
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Models
|
namespace DiscordChatExporter.Core.Models
|
||||||
{
|
{
|
||||||
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
// https://discordapp.com/developers/docs/resources/channel#attachment-object
|
||||||
|
|
||||||
public class Attachment
|
public partial class Attachment
|
||||||
{
|
{
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
|
@ -16,11 +18,7 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public string FileName { get; }
|
public string FileName { get; }
|
||||||
|
|
||||||
public bool IsImage => FileName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
|
public bool IsImage { get; }
|
||||||
FileName.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
FileName.EndsWith(".png", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
FileName.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
public FileSize FileSize { get; }
|
public FileSize FileSize { get; }
|
||||||
|
|
||||||
|
@ -32,8 +30,21 @@ namespace DiscordChatExporter.Core.Models
|
||||||
Height = height;
|
Height = height;
|
||||||
FileName = fileName;
|
FileName = fileName;
|
||||||
FileSize = fileSize;
|
FileSize = fileSize;
|
||||||
|
|
||||||
|
IsImage = GetIsImage(fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override string ToString() => 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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>
|
|
@ -14,31 +14,15 @@ namespace DiscordChatExporter.Core.Models
|
||||||
|
|
||||||
public bool IsAnimated { get; }
|
public bool IsAnimated { get; }
|
||||||
|
|
||||||
public string ImageUrl
|
public string ImageUrl { get; }
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
// Custom emoji
|
|
||||||
if (Id.IsNotBlank())
|
|
||||||
{
|
|
||||||
// Animated
|
|
||||||
if (IsAnimated)
|
|
||||||
return $"https://cdn.discordapp.com/emojis/{Id}.gif";
|
|
||||||
|
|
||||||
// Non-animated
|
|
||||||
return $"https://cdn.discordapp.com/emojis/{Id}.png";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard unicode emoji (via twemoji)
|
|
||||||
return $"https://twemoji.maxcdn.com/2/72x72/{GetTwemojiName(Name)}.png";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Emoji(string id, string name, bool isAnimated)
|
public Emoji(string id, string name, bool isAnimated)
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
Name = name;
|
Name = name;
|
||||||
IsAnimated = isAnimated;
|
IsAnimated = isAnimated;
|
||||||
|
|
||||||
|
ImageUrl = GetImageUrl(id, name, isAnimated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,7 +34,25 @@ namespace DiscordChatExporter.Core.Models
|
||||||
yield return char.ConvertToUtf32(emoji, i);
|
yield return char.ConvertToUtf32(emoji, i);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetTwemojiName(string emoji)
|
private static string GetTwemojiName(string emoji) =>
|
||||||
=> GetCodePoints(emoji).Select(i => i.ToString("x")).JoinToString("-");
|
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";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
58
DiscordChatExporter.Core.Models/User.cs
Normal file
58
DiscordChatExporter.Core.Models/User.cs
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
112
DiscordChatExporter.Core.Rendering/CsvChatLogRenderer.cs
Normal file
112
DiscordChatExporter.Core.Rendering/CsvChatLogRenderer.cs
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
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(DateTime date) => date.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>
|
|
@ -1,10 +1,10 @@
|
||||||
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
|
||||||
{
|
{
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
197
DiscordChatExporter.Core.Rendering/HtmlChatLogRenderer.cs
Normal file
197
DiscordChatExporter.Core.Rendering/HtmlChatLogRenderer.cs
Normal file
|
@ -0,0 +1,197 @@
|
||||||
|
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(DateTime date) => date.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<DateTime, 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
DiscordChatExporter.Core.Rendering/IChatLogRenderer.cs
Normal file
10
DiscordChatExporter.Core.Rendering/IChatLogRenderer.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Rendering
|
||||||
|
{
|
||||||
|
public interface IChatLogRenderer
|
||||||
|
{
|
||||||
|
Task RenderAsync(TextWriter writer);
|
||||||
|
}
|
||||||
|
}
|
128
DiscordChatExporter.Core.Rendering/PlainTextChatLogRenderer.cs
Normal file
128
DiscordChatExporter.Core.Rendering/PlainTextChatLogRenderer.cs
Normal file
|
@ -0,0 +1,128 @@
|
||||||
|
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(DateTime date) => date.ToString(_dateFormat, CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
private string FormatDateRange(DateTime? from, DateTime? to)
|
||||||
|
{
|
||||||
|
// Both 'from' and 'to'
|
||||||
|
if (from.HasValue && to.HasValue)
|
||||||
|
return $"{FormatDate(from.Value)} to {FormatDate(to.Value)}";
|
||||||
|
|
||||||
|
// Just 'from'
|
||||||
|
if (from.HasValue)
|
||||||
|
return $"after {FormatDate(from.Value)}";
|
||||||
|
|
||||||
|
// Just 'to'
|
||||||
|
if (to.HasValue)
|
||||||
|
return $"before {FormatDate(to.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.From, _chatLog.To)}");
|
||||||
|
await writer.WriteLineAsync('='.Repeat(62));
|
||||||
|
await writer.WriteLineAsync();
|
||||||
|
|
||||||
|
// Log
|
||||||
|
foreach (var message in _chatLog.Messages)
|
||||||
|
{
|
||||||
|
await RenderMessageAsync(writer, message);
|
||||||
|
await writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{{~ ThemeStyleSheet = include "HtmlDark.css" ~}}
|
||||||
|
{{~ HighlightJsStyleName = "solarized-dark" ~}}
|
||||||
|
{{~ include "HtmlShared.html" ~}}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{{~ ThemeStyleSheet = include "HtmlLight.css" ~}}
|
||||||
|
{{~ HighlightJsStyleName = "solarized-light" ~}}
|
||||||
|
{{~ include "HtmlShared.html" ~}}
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
{{~ # Styles ~}}
|
{{~ # Styles ~}}
|
||||||
<style>
|
<style>
|
||||||
{{ include "HtmlShared.Main.css" }}
|
{{ include "HtmlShared.css" }}
|
||||||
</style>
|
</style>
|
||||||
<style>
|
<style>
|
||||||
{{ ThemeStyleSheet }}
|
{{ ThemeStyleSheet }}
|
||||||
|
@ -41,7 +41,7 @@
|
||||||
<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.From || Model.To ~}}
|
||||||
<div class="info__channel-date-range">
|
<div class="info__channel-date-range">
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -41,14 +41,14 @@ 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, parentId, guildId, name, topic, type);
|
return new Channel(id, parentId, guildId, name, topic, type);
|
|
@ -4,11 +4,11 @@ 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 Newtonsoft.Json.Linq;
|
using DiscordChatExporter.Core.Services.Exceptions;
|
||||||
using DiscordChatExporter.Core.Internal;
|
using DiscordChatExporter.Core.Services.Internal;
|
||||||
using Failsafe;
|
using Failsafe;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using Tyrrrz.Extensions;
|
using Tyrrrz.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
namespace DiscordChatExporter.Core.Services
|
||||||
|
@ -40,13 +40,13 @@ namespace DiscordChatExporter.Core.Services
|
||||||
: new AuthenticationHeaderValue(token.Value);
|
: new AuthenticationHeaderValue(token.Value);
|
||||||
|
|
||||||
// Add parameters
|
// Add parameters
|
||||||
foreach (var parameter in parameters.ExceptBlank())
|
foreach (var parameter in parameters)
|
||||||
{
|
{
|
||||||
var key = parameter.SubstringUntil("=");
|
var key = parameter.SubstringUntil("=");
|
||||||
var value = parameter.SubstringAfter("=");
|
var value = parameter.SubstringAfter("=");
|
||||||
|
|
||||||
// Skip empty values
|
// Skip empty values
|
||||||
if (value.IsBlank())
|
if (value.IsEmpty())
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
request.RequestUri = request.RequestUri.SetQueryParameter(key, value);
|
|
@ -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,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
|
||||||
{
|
{
|
89
DiscordChatExporter.Core.Services/ExportService.cs
Normal file
89
DiscordChatExporter.Core.Services/ExportService.cs
Normal file
|
@ -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.From, chatLog.To, 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,16 +3,15 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Helpers
|
namespace DiscordChatExporter.Core.Services.Helpers
|
||||||
{
|
{
|
||||||
public static class ExportHelper
|
public static class ExportHelper
|
||||||
{
|
{
|
||||||
public static bool IsDirectoryPath(string path)
|
public static bool IsDirectoryPath(string path) =>
|
||||||
=> path.Last() == Path.DirectorySeparatorChar ||
|
path.Last() == Path.DirectorySeparatorChar ||
|
||||||
path.Last() == Path.AltDirectorySeparatorChar ||
|
path.Last() == Path.AltDirectorySeparatorChar ||
|
||||||
Path.GetExtension(path).IsBlank();
|
Path.GetExtension(path) == null;
|
||||||
|
|
||||||
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
|
public static string GetDefaultExportFileName(ExportFormat format, Guild guild, Channel channel,
|
||||||
DateTime? from = null, DateTime? to = null)
|
DateTime? from = null, DateTime? to = null)
|
18
DiscordChatExporter.Core.Services/Internal/Extensions.cs
Normal file
18
DiscordChatExporter.Core.Services/Internal/Extensions.cs
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
using System;
|
||||||
|
using System.Drawing;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Services.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 Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,31 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net46;netstandard2.0</TargetFrameworks>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\PlainText\Template.txt" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Template.html" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Template.html" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.html" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlShared\Main.css" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlDark\Theme.css" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\HtmlLight\Theme.css" />
|
|
||||||
<EmbeddedResource Include="Resources\ExportTemplates\Csv\Template.csv" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Failsafe" Version="1.1.0" />
|
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
|
|
||||||
<PackageReference Include="Onova" Version="2.4.2" />
|
|
||||||
<PackageReference Include="Scriban" Version="2.0.0" />
|
|
||||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.5.1" />
|
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -1,52 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Drawing;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
|
||||||
|
|
||||||
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 Color ResetAlpha(this Color color) => Color.FromArgb(1, color);
|
|
||||||
|
|
||||||
public static string HtmlEncode(this string value) => WebUtility.HtmlEncode(value);
|
|
||||||
|
|
||||||
public static IEnumerable<IReadOnlyList<T>> GroupAdjacentWhile<T>(this IEnumerable<T> source,
|
|
||||||
Func<IReadOnlyList<T>, T, bool> groupPredicate)
|
|
||||||
{
|
|
||||||
// Create buffer
|
|
||||||
var buffer = new List<T>();
|
|
||||||
|
|
||||||
// Enumerate source
|
|
||||||
foreach (var element in source)
|
|
||||||
{
|
|
||||||
// If buffer is not empty and group predicate failed - yield and flush buffer
|
|
||||||
if (buffer.Any() && !groupPredicate(buffer, element))
|
|
||||||
{
|
|
||||||
yield return buffer;
|
|
||||||
buffer = new List<T>(); // new instance to reset reference
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add element to buffer
|
|
||||||
buffer.Add(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If buffer still has something after the source has been enumerated - yield
|
|
||||||
if (buffer.Any())
|
|
||||||
yield return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IEnumerable<IReadOnlyList<T>> GroupAdjacentWhile<T>(this IEnumerable<T> source,
|
|
||||||
Func<IReadOnlyList<T>, bool> groupPredicate)
|
|
||||||
=> source.GroupAdjacentWhile((buffer, _) => groupPredicate(buffer));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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 | FormatMarkdown | string.replace "\"" "\"\"" }}";
|
|
||||||
|
|
||||||
{{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}";
|
|
||||||
{{~ end -}}
|
|
Can't render this file because it has a wrong number of fields in line 2.
|
|
@ -1,3 +0,0 @@
|
||||||
{{~ ThemeStyleSheet = include "HtmlDark.Theme.css" ~}}
|
|
||||||
{{~ HighlightJsStyleName = "solarized-dark" ~}}
|
|
||||||
{{~ include "HtmlShared.Main.html" ~}}
|
|
|
@ -1,3 +0,0 @@
|
||||||
{{~ ThemeStyleSheet = include "HtmlLight.Theme.css" ~}}
|
|
||||||
{{~ HighlightJsStyleName = "solarized-light" ~}}
|
|
||||||
{{~ include "HtmlShared.Main.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 | FormatMarkdown }}
|
|
||||||
{{~ # Attachments ~}}
|
|
||||||
{{~ for attachment in message.Attachments ~}}
|
|
||||||
{{~ attachment.Url }}
|
|
||||||
{{~ end ~}}
|
|
||||||
|
|
||||||
{{~ end ~}}
|
|
|
@ -1,43 +0,0 @@
|
||||||
using System.Reflection;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
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}.Template.{format.GetFileExtension()}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath)
|
|
||||||
{
|
|
||||||
return Assembly.GetExecutingAssembly().GetManifestResourceString(templatePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValueTask<string> LoadAsync(TemplateContext context, SourceSpan callerSpan, string templatePath)
|
|
||||||
{
|
|
||||||
return new ValueTask<string>(Load(context, callerSpan, templatePath));
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Load(ExportFormat format)
|
|
||||||
{
|
|
||||||
return Assembly.GetExecutingAssembly().GetManifestResourceString(GetPath(format));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,222 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using DiscordChatExporter.Core.Internal;
|
|
||||||
using DiscordChatExporter.Core.Markdown;
|
|
||||||
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;
|
|
||||||
|
|
||||||
public TemplateModel(ExportFormat format, ChatLog log, string dateFormat)
|
|
||||||
{
|
|
||||||
_format = format;
|
|
||||||
_log = log;
|
|
||||||
_dateFormat = dateFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
|
|
||||||
=> messages.GroupAdjacentWhile((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 Format(IFormattable obj, string format)
|
|
||||||
=> obj.ToString(format, CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
private string FormatDate(DateTime dateTime) => Format(dateTime, _dateFormat);
|
|
||||||
|
|
||||||
private string FormatMarkdownPlainText(IReadOnlyList<Node> nodes)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var node in nodes)
|
|
||||||
{
|
|
||||||
if (node is FormattedNode formattedNode)
|
|
||||||
{
|
|
||||||
var innerText = FormatMarkdownPlainText(formattedNode.Children);
|
|
||||||
buffer.Append($"{formattedNode.Token}{innerText}{formattedNode.Token}");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is MentionNode mentionNode && mentionNode.Type != MentionType.Meta)
|
|
||||||
{
|
|
||||||
if (mentionNode.Type == MentionType.User)
|
|
||||||
{
|
|
||||||
var user = _log.Mentionables.GetUser(mentionNode.Id);
|
|
||||||
buffer.Append($"@{user.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (mentionNode.Type == MentionType.Channel)
|
|
||||||
{
|
|
||||||
var channel = _log.Mentionables.GetChannel(mentionNode.Id);
|
|
||||||
buffer.Append($"#{channel.Name}");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (mentionNode.Type == MentionType.Role)
|
|
||||||
{
|
|
||||||
var role = _log.Mentionables.GetRole(mentionNode.Id);
|
|
||||||
buffer.Append($"@{role.Name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is EmojiNode emojiNode)
|
|
||||||
{
|
|
||||||
buffer.Append(emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : node.Lexeme);
|
|
||||||
}
|
|
||||||
|
|
||||||
else
|
|
||||||
{
|
|
||||||
buffer.Append(node.Lexeme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatMarkdownPlainText(string input)
|
|
||||||
=> FormatMarkdownPlainText(MarkdownParser.Parse(input));
|
|
||||||
|
|
||||||
private string FormatMarkdownHtml(IReadOnlyList<Node> nodes, int depth = 0)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder();
|
|
||||||
|
|
||||||
foreach (var node in nodes)
|
|
||||||
{
|
|
||||||
if (node is TextNode textNode)
|
|
||||||
{
|
|
||||||
buffer.Append(textNode.Text.HtmlEncode());
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is FormattedNode formattedNode)
|
|
||||||
{
|
|
||||||
var innerHtml = FormatMarkdownHtml(formattedNode.Children, depth + 1);
|
|
||||||
|
|
||||||
if (formattedNode.Formatting == TextFormatting.Bold)
|
|
||||||
buffer.Append($"<strong>{innerHtml}</strong>");
|
|
||||||
|
|
||||||
else if (formattedNode.Formatting == TextFormatting.Italic)
|
|
||||||
buffer.Append($"<em>{innerHtml}</em>");
|
|
||||||
|
|
||||||
else if (formattedNode.Formatting == TextFormatting.Underline)
|
|
||||||
buffer.Append($"<u>{innerHtml}</u>");
|
|
||||||
|
|
||||||
else if (formattedNode.Formatting == TextFormatting.Strikethrough)
|
|
||||||
buffer.Append($"<s>{innerHtml}</s>");
|
|
||||||
|
|
||||||
else if (formattedNode.Formatting == TextFormatting.Spoiler)
|
|
||||||
buffer.Append($"<span class=\"spoiler\">{innerHtml}</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is InlineCodeBlockNode inlineCodeBlockNode)
|
|
||||||
{
|
|
||||||
buffer.Append($"<span class=\"pre pre--inline\">{inlineCodeBlockNode.Code.HtmlEncode()}</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is MultilineCodeBlockNode multilineCodeBlockNode)
|
|
||||||
{
|
|
||||||
// Set language class for syntax highlighting
|
|
||||||
var languageCssClass = multilineCodeBlockNode.Language.IsNotBlank()
|
|
||||||
? "language-" + multilineCodeBlockNode.Language
|
|
||||||
: null;
|
|
||||||
|
|
||||||
buffer.Append(
|
|
||||||
$"<div class=\"pre pre--multiline {languageCssClass}\">{multilineCodeBlockNode.Code.HtmlEncode()}</div>");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is MentionNode mentionNode)
|
|
||||||
{
|
|
||||||
if (mentionNode.Type == MentionType.Meta)
|
|
||||||
{
|
|
||||||
buffer.Append($"<span class=\"mention\">@{mentionNode.Id.HtmlEncode()}</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (mentionNode.Type == MentionType.User)
|
|
||||||
{
|
|
||||||
var user = _log.Mentionables.GetUser(mentionNode.Id);
|
|
||||||
buffer.Append($"<span class=\"mention\" title=\"{user.FullName}\">@{user.Name.HtmlEncode()}</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (mentionNode.Type == MentionType.Channel)
|
|
||||||
{
|
|
||||||
var channel = _log.Mentionables.GetChannel(mentionNode.Id);
|
|
||||||
buffer.Append($"<span class=\"mention\">#{channel.Name.HtmlEncode()}</span>");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (mentionNode.Type == MentionType.Role)
|
|
||||||
{
|
|
||||||
var role = _log.Mentionables.GetRole(mentionNode.Id);
|
|
||||||
buffer.Append($"<span class=\"mention\">@{role.Name.HtmlEncode()}</span>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is EmojiNode emojiNode)
|
|
||||||
{
|
|
||||||
// Get emoji image URL
|
|
||||||
var emojiImageUrl = new Emoji(emojiNode.Id, emojiNode.Name, emojiNode.IsAnimated).ImageUrl;
|
|
||||||
|
|
||||||
// Emoji can be jumboable if it's the only top-level node
|
|
||||||
var jumboableCssClass = depth == 0 && nodes.Count == 1
|
|
||||||
? "emoji--large"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
buffer.Append($"<img class=\"emoji {jumboableCssClass}\" alt=\"{emojiNode.Name}\" title=\"{emojiNode.Name}\" src=\"{emojiImageUrl}\" />");
|
|
||||||
}
|
|
||||||
|
|
||||||
else if (node is LinkNode linkNode)
|
|
||||||
{
|
|
||||||
var escapedUrl = Uri.EscapeUriString(linkNode.Url);
|
|
||||||
buffer.Append($"<a href=\"{escapedUrl}\">{linkNode.Title.HtmlEncode()}</a>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatMarkdownHtml(string input)
|
|
||||||
=> FormatMarkdownHtml(MarkdownParser.Parse(input));
|
|
||||||
|
|
||||||
private string FormatMarkdown(string input)
|
|
||||||
{
|
|
||||||
return _format == ExportFormat.HtmlDark || _format == ExportFormat.HtmlLight
|
|
||||||
? FormatMarkdownHtml(input)
|
|
||||||
: FormatMarkdownPlainText(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
public ScriptObject GetScriptObject()
|
|
||||||
{
|
|
||||||
// Create instance
|
|
||||||
var scriptObject = new ScriptObject();
|
|
||||||
|
|
||||||
// Import model
|
|
||||||
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(FormatMarkdown), new Func<string, string>(FormatMarkdown));
|
|
||||||
|
|
||||||
return scriptObject;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,107 +0,0 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using DiscordChatExporter.Core.Internal;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
|
||||||
using Scriban;
|
|
||||||
using Scriban.Runtime;
|
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Services
|
|
||||||
{
|
|
||||||
public partial class ExportService
|
|
||||||
{
|
|
||||||
private readonly SettingsService _settingsService;
|
|
||||||
|
|
||||||
public ExportService(SettingsService settingsService)
|
|
||||||
{
|
|
||||||
_settingsService = settingsService;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExportChatLogSingleAsync(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);
|
|
||||||
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
|
|
||||||
await context.EvaluateAsync(template.Page);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExportChatLogPartitionedAsync(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} of {partitions.Count}]{fileExt}";
|
|
||||||
|
|
||||||
// Compose full file path
|
|
||||||
if (dirPath.IsNotBlank())
|
|
||||||
partitionFilePath = Path.Combine(dirPath, partitionFilePath);
|
|
||||||
|
|
||||||
// Export
|
|
||||||
await ExportChatLogSingleAsync(partition, partitionFilePath, format);
|
|
||||||
|
|
||||||
// Increment partition number
|
|
||||||
partitionNumber++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ExportChatLogAsync(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 || partitionLimit <= 0 || chatLog.Messages.Count <= partitionLimit)
|
|
||||||
{
|
|
||||||
await ExportChatLogSingleAsync(chatLog, filePath, format);
|
|
||||||
}
|
|
||||||
// Otherwise split into partitions and export separately
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Create partitions by grouping up to X adjacent messages into separate chat logs
|
|
||||||
var partitions = chatLog.Messages.GroupAdjacentWhile(g => g.Count < partitionLimit.Value)
|
|
||||||
.Select(g => new ChatLog(chatLog.Guild, chatLog.Channel, chatLog.From, chatLog.To, g, chatLog.Mentionables))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
await ExportChatLogPartitionedAsync(partitions, filePath, format);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -14,7 +14,7 @@ namespace DiscordChatExporter.Gui
|
||||||
{
|
{
|
||||||
base.ConfigureIoC(builder);
|
base.ConfigureIoC(builder);
|
||||||
|
|
||||||
// Autobind services in the .Core assembly
|
// Autobind the .Services assembly
|
||||||
builder.Autobind(typeof(DataService).Assembly);
|
builder.Autobind(typeof(DataService).Assembly);
|
||||||
|
|
||||||
// Bind settings as singleton
|
// Bind settings as singleton
|
||||||
|
|
|
@ -97,9 +97,13 @@
|
||||||
<Resource Include="..\favicon.ico" />
|
<Resource Include="..\favicon.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj">
|
<ProjectReference Include="..\DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj">
|
||||||
|
<Project>{67a9d184-4656-4ce1-9d75-bddcbcafb200}</Project>
|
||||||
|
<Name>DiscordChatExporter.Core.Models</Name>
|
||||||
|
</ProjectReference>
|
||||||
|
<ProjectReference Include="..\DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj">
|
||||||
<Project>{707c0cd0-a7e0-4cab-8db9-07a45cb87377}</Project>
|
<Project>{707c0cd0-a7e0-4cab-8db9-07a45cb87377}</Project>
|
||||||
<Name>DiscordChatExporter.Core</Name>
|
<Name>DiscordChatExporter.Core.Services</Name>
|
||||||
</ProjectReference>
|
</ProjectReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
@ -143,7 +147,7 @@
|
||||||
<Version>2.0.20525</Version>
|
<Version>2.0.20525</Version>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Tyrrrz.Extensions">
|
<PackageReference Include="Tyrrrz.Extensions">
|
||||||
<Version>1.5.1</Version>
|
<Version>1.6.0</Version>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DiscordChatExporter.Core.Helpers;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
using Tyrrrz.Extensions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||||
{
|
{
|
||||||
|
@ -85,7 +84,7 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
|
||||||
}
|
}
|
||||||
|
|
||||||
// If canceled - return
|
// If canceled - return
|
||||||
if (OutputPath.IsBlank())
|
if (OutputPath == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Close dialog
|
// Close dialog
|
||||||
|
|
|
@ -4,10 +4,10 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
|
||||||
using DiscordChatExporter.Core.Helpers;
|
|
||||||
using DiscordChatExporter.Core.Models;
|
using DiscordChatExporter.Core.Models;
|
||||||
using DiscordChatExporter.Core.Services;
|
using DiscordChatExporter.Core.Services;
|
||||||
|
using DiscordChatExporter.Core.Services.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Services.Helpers;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
using Gress;
|
using Gress;
|
||||||
|
@ -62,9 +62,9 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||||
// Update busy state when progress manager changes
|
// Update busy state when progress manager changes
|
||||||
ProgressManager.Bind(o => o.IsActive, (sender, args) => IsBusy = ProgressManager.IsActive);
|
ProgressManager.Bind(o => o.IsActive, (sender, args) => IsBusy = ProgressManager.IsActive);
|
||||||
ProgressManager.Bind(o => o.IsActive,
|
ProgressManager.Bind(o => o.IsActive,
|
||||||
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress <= 0);
|
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
||||||
ProgressManager.Bind(o => o.Progress,
|
ProgressManager.Bind(o => o.Progress,
|
||||||
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress <= 0);
|
(sender, args) => IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress.IsEither(0, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async void OnViewLoaded()
|
protected override async void OnViewLoaded()
|
||||||
|
@ -122,7 +122,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||||
await _dialogManager.ShowDialogAsync(dialog);
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanPopulateGuildsAndChannels => !IsBusy && TokenValue.IsNotBlank();
|
public bool CanPopulateGuildsAndChannels => !IsBusy && !TokenValue.EmptyIfNull().IsWhiteSpace();
|
||||||
|
|
||||||
public async void PopulateGuildsAndChannels()
|
public async void PopulateGuildsAndChannels()
|
||||||
{
|
{
|
||||||
|
@ -235,7 +235,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool CanExportChannels => !IsBusy && SelectedChannels.NotNullAndAny();
|
public bool CanExportChannels => !IsBusy && SelectedChannels.EmptyIfNull().Any();
|
||||||
|
|
||||||
public async void ExportChannels()
|
public async void ExportChannels()
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
# Visual Studio 15
|
# Visual Studio Version 16
|
||||||
VisualStudioVersion = 15.0.27130.2026
|
VisualStudioVersion = 16.0.28729.10
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
@ -10,36 +10,48 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
||||||
Readme.md = Readme.md
|
Readme.md = Readme.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Markdown", "DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj", "{14D02A08-E820-4012-B805-663B9A3D73E9}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Models", "DiscordChatExporter.Core.Models\DiscordChatExporter.Core.Models.csproj", "{67A9D184-4656-4CE1-9D75-BDDCBCAFB200}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Rendering", "DiscordChatExporter.Core.Rendering\DiscordChatExporter.Core.Rendering.csproj", "{D33F7443-4EEB-4E53-99BE-6045A62FC8C8}"
|
||||||
|
EndProject
|
||||||
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core.Services", "DiscordChatExporter.Core.Services\DiscordChatExporter.Core.Services.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}"
|
||||||
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core", "DiscordChatExporter.Core\DiscordChatExporter.Core.csproj", "{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}"
|
|
||||||
EndProject
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Core.Markdown", "DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj", "{14D02A08-E820-4012-B805-663B9A3D73E9}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{14D02A08-E820-4012-B805-663B9A3D73E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{14D02A08-E820-4012-B805-663B9A3D73E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{14D02A08-E820-4012-B805-663B9A3D73E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{14D02A08-E820-4012-B805-663B9A3D73E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{14D02A08-E820-4012-B805-663B9A3D73E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{14D02A08-E820-4012-B805-663B9A3D73E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{14D02A08-E820-4012-B805-663B9A3D73E9}.Release|Any CPU.Build.0 = Release|Any CPU
|
{14D02A08-E820-4012-B805-663B9A3D73E9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{67A9D184-4656-4CE1-9D75-BDDCBCAFB200}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D33F7443-4EEB-4E53-99BE-6045A62FC8C8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{707C0CD0-A7E0-4CAB-8DB9-07A45CB87377}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{732A67AF-93DE-49DF-B10F-FD74710B7863}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D08624B6-3081-4BCB-91F8-E9832FACC6CE}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|
13
Dockerfile
13
Dockerfile
|
@ -4,17 +4,10 @@ WORKDIR /src
|
||||||
|
|
||||||
COPY favicon.ico ./
|
COPY favicon.ico ./
|
||||||
|
|
||||||
COPY DiscordChatExporter.Core.Markdown/*.csproj DiscordChatExporter.Core.Markdown/
|
|
||||||
RUN dotnet restore DiscordChatExporter.Core.Markdown
|
|
||||||
|
|
||||||
COPY DiscordChatExporter.Core/*.csproj DiscordChatExporter.Core/
|
|
||||||
RUN dotnet restore DiscordChatExporter.Core
|
|
||||||
|
|
||||||
COPY DiscordChatExporter.Cli/*.csproj DiscordChatExporter.Cli/
|
|
||||||
RUN dotnet restore DiscordChatExporter.Cli
|
|
||||||
|
|
||||||
COPY DiscordChatExporter.Core.Markdown DiscordChatExporter.Core.Markdown
|
COPY DiscordChatExporter.Core.Markdown DiscordChatExporter.Core.Markdown
|
||||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
COPY DiscordChatExporter.Core.Models DiscordChatExporter.Core.Models
|
||||||
|
COPY DiscordChatExporter.Core.Rendering DiscordChatExporter.Core.Rendering
|
||||||
|
COPY DiscordChatExporter.Core.Services DiscordChatExporter.Core.Services
|
||||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||||
|
|
||||||
RUN dotnet publish DiscordChatExporter.Cli -c Release -f netcoreapp2.1
|
RUN dotnet publish DiscordChatExporter.Cli -c Release -f netcoreapp2.1
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue