Improve performance (#162)

This commit is contained in:
Alexey Golub 2019-04-10 23:45:21 +03:00 committed by GitHub
parent 359278afec
commit 4bfb2ec7fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 1242 additions and 900 deletions

View file

@ -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

View file

@ -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>

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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>

View file

@ -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;
}
}
}

View 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);
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,7 @@
namespace DiscordChatExporter.Core.Markdown.Internal
{
internal interface IMatcher<T>
{
ParsedMatch<T> Match(string input, int startIndex, int length);
}
}

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}
}

View file

@ -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);
} }
} }

View file

@ -1,12 +0,0 @@
namespace DiscordChatExporter.Core.Markdown
{
public abstract class Node
{
public string Lexeme { get; }
protected Node(string lexeme)
{
Lexeme = lexeme;
}
}
}

View file

@ -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)
{ {
} }

View file

@ -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;

View file

@ -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;
} }

View file

@ -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)
{ {
} }

View file

@ -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;

View file

@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown namespace DiscordChatExporter.Core.Markdown.Nodes
{ {
public enum MentionType public enum MentionType
{ {

View file

@ -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;

View file

@ -0,0 +1,12 @@
namespace DiscordChatExporter.Core.Markdown.Nodes
{
public abstract class Node
{
public string Source { get; }
protected Node(string source)
{
Source = source;
}
}
}

View file

@ -1,4 +1,4 @@
namespace DiscordChatExporter.Core.Markdown namespace DiscordChatExporter.Core.Markdown.Nodes
{ {
public enum TextFormatting public enum TextFormatting
{ {

View file

@ -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;
} }

View file

@ -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);
}
}
} }

View file

@ -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>

View file

@ -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";
}
} }
} }

View file

@ -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);
} }
} }

View file

@ -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");
} }
} }

View 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);
}
}

View 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);
}
}
}

View file

@ -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>

View file

@ -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
{ {

View file

@ -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));
}
}
}

View 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));
}
}
}

View file

@ -0,0 +1,10 @@
using System.IO;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Rendering
{
public interface IChatLogRenderer
{
Task RenderAsync(TextWriter writer);
}
}

View 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();
}
}
}
}

View file

@ -0,0 +1,3 @@
{{~ ThemeStyleSheet = include "HtmlDark.css" ~}}
{{~ HighlightJsStyleName = "solarized-dark" ~}}
{{~ include "HtmlShared.html" ~}}

View file

@ -0,0 +1,3 @@
{{~ ThemeStyleSheet = include "HtmlLight.css" ~}}
{{~ HighlightJsStyleName = "solarized-light" ~}}
{{~ include "HtmlShared.html" ~}}

View file

@ -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">

View file

@ -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);

View file

@ -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);

View file

@ -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>

View file

@ -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
{ {

View 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++;
}
}
}
}
}

View file

@ -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)

View 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);
}
}

View file

@ -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>

View file

@ -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));
}
}

View file

@ -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);
}
}

View file

@ -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.

View file

@ -1,3 +0,0 @@
{{~ ThemeStyleSheet = include "HtmlDark.Theme.css" ~}}
{{~ HighlightJsStyleName = "solarized-dark" ~}}
{{~ include "HtmlShared.Main.html" ~}}

View file

@ -1,3 +0,0 @@
{{~ ThemeStyleSheet = include "HtmlLight.Theme.css" ~}}
{{~ HighlightJsStyleName = "solarized-light" ~}}
{{~ include "HtmlShared.Main.html" ~}}

View file

@ -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 ~}}

View file

@ -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));
}
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}
}
}

View file

@ -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

View file

@ -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" />

View file

@ -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

View file

@ -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()
{ {

View file

@ -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

View file

@ -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