From f09f30c7bdac7bf0be7f11c5cf045fee0d71471f Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Sun, 3 Mar 2019 14:16:12 +0200 Subject: [PATCH] Implement a more sophisticated markdown parsing engine (#145) --- .../DiscordChatExporter.Core.Markdown.csproj | 12 + .../EmojiNode.cs | 21 ++ .../FormattedNode.cs | 23 ++ .../InlineCodeBlockNode.cs | 15 + .../Internal/Grammar.cs | 157 ++++++++ DiscordChatExporter.Core.Markdown/LinkNode.cs | 22 ++ .../MarkdownParser.cs | 10 + .../MentionNode.cs | 18 + .../MentionType.cs | 10 + .../MultilineCodeBlockNode.cs | 18 + DiscordChatExporter.Core.Markdown/Node.cs | 12 + .../TextFormatting.cs | 11 + DiscordChatExporter.Core.Markdown/TextNode.cs | 19 + .../DiscordChatExporter.Core.csproj | 21 +- .../Internal/Extensions.cs | 8 +- .../{Csv.csv => Csv/Template.csv} | 2 +- .../Resources/ExportTemplates/HtmlDark.html | 7 - .../ExportTemplates/HtmlDark/Template.html | 2 + .../DarkTheme.css => HtmlDark/Theme.css} | 27 +- .../Resources/ExportTemplates/HtmlLight.html | 7 - .../ExportTemplates/HtmlLight/Template.html | 2 + .../LightTheme.css => HtmlLight/Theme.css} | 14 +- .../{Html/Shared.css => HtmlShared/Main.css} | 21 +- .../{Html/Core.html => HtmlShared/Main.html} | 19 +- .../{PlainText.txt => PlainText/Template.txt} | 2 +- .../Services/ExportService.TemplateLoader.cs | 2 +- .../Services/ExportService.TemplateModel.cs | 342 ++++++------------ DiscordChatExporter.sln | 6 + 28 files changed, 543 insertions(+), 287 deletions(-) create mode 100644 DiscordChatExporter.Core.Markdown/DiscordChatExporter.Core.Markdown.csproj create mode 100644 DiscordChatExporter.Core.Markdown/EmojiNode.cs create mode 100644 DiscordChatExporter.Core.Markdown/FormattedNode.cs create mode 100644 DiscordChatExporter.Core.Markdown/InlineCodeBlockNode.cs create mode 100644 DiscordChatExporter.Core.Markdown/Internal/Grammar.cs create mode 100644 DiscordChatExporter.Core.Markdown/LinkNode.cs create mode 100644 DiscordChatExporter.Core.Markdown/MarkdownParser.cs create mode 100644 DiscordChatExporter.Core.Markdown/MentionNode.cs create mode 100644 DiscordChatExporter.Core.Markdown/MentionType.cs create mode 100644 DiscordChatExporter.Core.Markdown/MultilineCodeBlockNode.cs create mode 100644 DiscordChatExporter.Core.Markdown/Node.cs create mode 100644 DiscordChatExporter.Core.Markdown/TextFormatting.cs create mode 100644 DiscordChatExporter.Core.Markdown/TextNode.cs rename DiscordChatExporter.Core/Resources/ExportTemplates/{Csv.csv => Csv/Template.csv} (72%) delete mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Template.html rename DiscordChatExporter.Core/Resources/ExportTemplates/{Html/DarkTheme.css => HtmlDark/Theme.css} (66%) delete mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html create mode 100644 DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Template.html rename DiscordChatExporter.Core/Resources/ExportTemplates/{Html/LightTheme.css => HtmlLight/Theme.css} (81%) rename DiscordChatExporter.Core/Resources/ExportTemplates/{Html/Shared.css => HtmlShared/Main.css} (96%) rename DiscordChatExporter.Core/Resources/ExportTemplates/{Html/Core.html => HtmlShared/Main.html} (92%) rename DiscordChatExporter.Core/Resources/ExportTemplates/{PlainText.txt => PlainText/Template.txt} (93%) diff --git a/DiscordChatExporter.Core.Markdown/DiscordChatExporter.Core.Markdown.csproj b/DiscordChatExporter.Core.Markdown/DiscordChatExporter.Core.Markdown.csproj new file mode 100644 index 0000000..e917ee7 --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/DiscordChatExporter.Core.Markdown.csproj @@ -0,0 +1,12 @@ + + + + net461 + + + + + + + + diff --git a/DiscordChatExporter.Core.Markdown/EmojiNode.cs b/DiscordChatExporter.Core.Markdown/EmojiNode.cs new file mode 100644 index 0000000..78407ce --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/EmojiNode.cs @@ -0,0 +1,21 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public class EmojiNode : Node + { + public string Id { get; } + + public string Name { get; } + + public bool IsAnimated { get; } + + public EmojiNode(string lexeme, string id, string name, bool isAnimated) + : base(lexeme) + { + Id = id; + Name = name; + IsAnimated = isAnimated; + } + + public override string ToString() => $" {Name}"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/FormattedNode.cs b/DiscordChatExporter.Core.Markdown/FormattedNode.cs new file mode 100644 index 0000000..131b811 --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/FormattedNode.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace DiscordChatExporter.Core.Markdown +{ + public class FormattedNode : Node + { + public string Token { get; } + + public TextFormatting Formatting { get; } + + public IReadOnlyList Children { get; } + + public FormattedNode(string lexeme, string token, TextFormatting formatting, IReadOnlyList children) + : base(lexeme) + { + Token = token; + Formatting = formatting; + Children = children; + } + + public override string ToString() => $"<{Formatting}> ({Children.Count} direct children)"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/InlineCodeBlockNode.cs b/DiscordChatExporter.Core.Markdown/InlineCodeBlockNode.cs new file mode 100644 index 0000000..588b1a7 --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/InlineCodeBlockNode.cs @@ -0,0 +1,15 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public class InlineCodeBlockNode : Node + { + public string Code { get; } + + public InlineCodeBlockNode(string lexeme, string code) + : base(lexeme) + { + Code = code; + } + + public override string ToString() => $" {Code}"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Internal/Grammar.cs b/DiscordChatExporter.Core.Markdown/Internal/Grammar.cs new file mode 100644 index 0000000..61a2dee --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/Internal/Grammar.cs @@ -0,0 +1,157 @@ +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 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 ItalicFormattedNode = + Parse.RegexMatch(new Regex("\\*(?!\\s)(.+?)(? 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 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 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 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 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 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 SpoilerFormattedNode = + Parse.RegexMatch(new Regex("\\|\\|(.+?)\\|\\|", RegexOptions.Singleline)) + .Select(m => new FormattedNode(m.Value, "||", TextFormatting.Spoiler, BuildTree(m.Groups[1].Value))); + + // Aggregator, order matters + private static readonly Parser 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 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 MultilineCodeBlockNode = + Parse.RegexMatch(new Regex("```(?:(\\w*?)?(?:\\s*?\\n))?(.+)```", RegexOptions.Singleline)) + .Select(m => new MultilineCodeBlockNode(m.Value, m.Groups[1].Value, m.Groups[2].Value)); + + // Aggregator, order matters + private static readonly Parser AnyCodeBlockNode = MultilineCodeBlockNode.Or(InlineCodeBlockNode); + + /* Mentions */ + + // @everyone or @here + private static readonly Parser MetaMentionNode = Parse.RegexMatch("@(everyone|here)") + .Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Meta)); + + // <@123456> or <@!123456> + private static readonly Parser UserMentionNode = Parse.RegexMatch("<@!?(\\d+)>") + .Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.User)); + + // <#123456> + private static readonly Parser ChannelMentionNode = Parse.RegexMatch("<#(\\d+)>") + .Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Channel)); + + // <@&123456> + private static readonly Parser RoleMentionNode = Parse.RegexMatch("<@&(\\d+)>") + .Select(m => new MentionNode(m.Value, m.Groups[1].Value, MentionType.Role)); + + // Aggregator, order matters + private static readonly Parser AnyMentionNode = + MetaMentionNode.Or(UserMentionNode).Or(ChannelMentionNode).Or(RoleMentionNode); + + /* Emojis */ + + // <:lul:123456> or + private static readonly Parser EmojiNode = Parse.RegexMatch("<(a)?:(.+):(\\d+)>") + .Select(m => new EmojiNode(m.Value, m.Groups[3].Value, m.Groups[2].Value, m.Groups[1].Value.IsNotBlank())); + + // Aggregator, order matters + private static readonly Parser AnyEmojiNode = EmojiNode; + + /* Links */ + + // [title](link) + private static readonly Parser 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 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 HiddenLinkNode = Parse.RegexMatch("<(https?://\\S*[^\\.,:;\"\'\\s])>") + .Select(m => new LinkNode(m.Value, m.Groups[1].Value)); + + // Aggregator, order matters + private static readonly Parser AnyLinkNode = TitledLinkNode.Or(HiddenLinkNode).Or(AutoLinkNode); + + /* Text */ + + // Shrug is an exception and needs to be exempt from formatting + private static readonly Parser ShrugTextNode = + Parse.String("¯\\_(ツ)_/¯").Text().Select(s => new TextNode(s)); + + // Backslash escapes any following non-whitespace character except for digits and latin letters + private static readonly Parser EscapedTextNode = + Parse.RegexMatch("\\\\([^a-zA-Z0-9\\s])").Select(m => new TextNode(m.Value, m.Groups[1].Value)); + + // Aggregator, order matters + private static readonly Parser AnyTextNode = ShrugTextNode.Or(EscapedTextNode); + + /* Aggregator and fallback */ + + // Any node recognized by above patterns + private static readonly Parser 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 FallbackNode = + Parse.AnyChar.Except(AnyRecognizedNode).AtLeastOnce().Text().Select(s => new TextNode(s)); + + // Any node + private static readonly Parser AnyNode = AnyRecognizedNode.Or(FallbackNode); + + // Entry point + public static IReadOnlyList BuildTree(string input) => AnyNode.Many().Parse(input).ToArray(); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/LinkNode.cs b/DiscordChatExporter.Core.Markdown/LinkNode.cs new file mode 100644 index 0000000..637eb78 --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/LinkNode.cs @@ -0,0 +1,22 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public class LinkNode : Node + { + public string Url { get; } + + public string Title { get; } + + public LinkNode(string lexeme, string url, string title) + : base(lexeme) + { + Url = url; + Title = title; + } + + public LinkNode(string lexeme, string url) : this(lexeme, url, url) + { + } + + public override string ToString() => $" {Title}"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/MarkdownParser.cs b/DiscordChatExporter.Core.Markdown/MarkdownParser.cs new file mode 100644 index 0000000..679a6ad --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/MarkdownParser.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using DiscordChatExporter.Core.Markdown.Internal; + +namespace DiscordChatExporter.Core.Markdown +{ + public static class MarkdownParser + { + public static IReadOnlyList Parse(string input) => Grammar.BuildTree(input); + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/MentionNode.cs b/DiscordChatExporter.Core.Markdown/MentionNode.cs new file mode 100644 index 0000000..4031baa --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/MentionNode.cs @@ -0,0 +1,18 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public class MentionNode : Node + { + public string Id { get; } + + public MentionType Type { get; } + + public MentionNode(string lexeme, string id, MentionType type) + : base(lexeme) + { + Id = id; + Type = type; + } + + public override string ToString() => $"<{Type} mention> {Id}"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/MentionType.cs b/DiscordChatExporter.Core.Markdown/MentionType.cs new file mode 100644 index 0000000..c5cef3c --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/MentionType.cs @@ -0,0 +1,10 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public enum MentionType + { + Meta, + User, + Channel, + Role + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/MultilineCodeBlockNode.cs b/DiscordChatExporter.Core.Markdown/MultilineCodeBlockNode.cs new file mode 100644 index 0000000..62bc969 --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/MultilineCodeBlockNode.cs @@ -0,0 +1,18 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public class MultilineCodeBlockNode : Node + { + public string Language { get; } + + public string Code { get; } + + public MultilineCodeBlockNode(string lexeme, string language, string code) + : base(lexeme) + { + Language = language; + Code = code; + } + + public override string ToString() => $" {Code}"; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/Node.cs b/DiscordChatExporter.Core.Markdown/Node.cs new file mode 100644 index 0000000..2b7908a --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/Node.cs @@ -0,0 +1,12 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public abstract class Node + { + public string Lexeme { get; } + + protected Node(string lexeme) + { + Lexeme = lexeme; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/TextFormatting.cs b/DiscordChatExporter.Core.Markdown/TextFormatting.cs new file mode 100644 index 0000000..175aece --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/TextFormatting.cs @@ -0,0 +1,11 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public enum TextFormatting + { + Bold, + Italic, + Underline, + Strikethrough, + Spoiler + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Markdown/TextNode.cs b/DiscordChatExporter.Core.Markdown/TextNode.cs new file mode 100644 index 0000000..a7da397 --- /dev/null +++ b/DiscordChatExporter.Core.Markdown/TextNode.cs @@ -0,0 +1,19 @@ +namespace DiscordChatExporter.Core.Markdown +{ + public class TextNode : Node + { + public string Text { get; } + + public TextNode(string lexeme, string text) + : base(lexeme) + { + Text = text; + } + + public TextNode(string text) : this(text, text) + { + } + + public override string ToString() => Text; + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj index 928378b..276e305 100644 --- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj +++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj @@ -2,18 +2,17 @@ net461 - 2.9.1 - - - - - - - - + + + + + + + + @@ -29,4 +28,8 @@ + + + + \ No newline at end of file diff --git a/DiscordChatExporter.Core/Internal/Extensions.cs b/DiscordChatExporter.Core/Internal/Extensions.cs index 90cdfaf..84d96f4 100644 --- a/DiscordChatExporter.Core/Internal/Extensions.cs +++ b/DiscordChatExporter.Core/Internal/Extensions.cs @@ -1,6 +1,6 @@ using System; using System.Drawing; -using Tyrrrz.Extensions; +using System.Net; namespace DiscordChatExporter.Core.Internal { @@ -14,10 +14,8 @@ namespace DiscordChatExporter.Core.Internal return value.ToString(); } - public static string Base64Encode(this string str) => str.GetBytes().ToBase64(); - - public static string Base64Decode(this string str) => str.FromBase64().GetString(); - public static Color ResetAlpha(this Color color) => Color.FromArgb(1, color); + + public static string HtmlEncode(this string value) => WebUtility.HtmlEncode(value); } } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Csv.csv b/DiscordChatExporter.Core/Resources/ExportTemplates/Csv/Template.csv similarity index 72% rename from DiscordChatExporter.Core/Resources/ExportTemplates/Csv.csv rename to DiscordChatExporter.Core/Resources/ExportTemplates/Csv/Template.csv index 7643ad2..ce04f1d 100644 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/Csv.csv +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/Csv/Template.csv @@ -4,7 +4,7 @@ {{- }}"{{ message.Timestamp | FormatDate }}"; - {{- }}"{{ message.Content | FormatContent }}"; + {{- }}"{{ message.Content | FormatMarkdown | string.replace "\"" "\"\"" }}"; {{- }}"{{ message.Attachments | array.map "Url" | array.join "," }}"; {{~ end -}} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html deleted file mode 100644 index 24575e3..0000000 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark.html +++ /dev/null @@ -1,7 +0,0 @@ -{{ - $SharedStyleSheet = include "Html.Shared.css" - $ThemeStyleSheet = include "Html.DarkTheme.css" - StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet -}} - -{{ include "Html.Core.html" }} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Template.html b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Template.html new file mode 100644 index 0000000..57df965 --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Template.html @@ -0,0 +1,2 @@ +{{~ ThemeStyleSheet = include "HtmlDark.Theme.css" ~}} +{{~ include "HtmlShared.Main.html" ~}} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/DarkTheme.css b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Theme.css similarity index 66% rename from DiscordChatExporter.Core/Resources/ExportTemplates/Html/DarkTheme.css rename to DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Theme.css index d2d4be0..6766b7c 100644 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/DarkTheme.css +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlDark/Theme.css @@ -2,13 +2,17 @@ body { background-color: #36393e; - color: #ffffffb3; + color: #dcddde; } a { color: #0096cf; } +.spoiler { + background-color: rgba(255, 255, 255, 0.1); +} + .pre { background-color: #2f3136; } @@ -19,7 +23,6 @@ a { } .mention { - background-color: #738bd71a; color: #7289da; } @@ -40,7 +43,7 @@ a { /* === CHATLOG === */ .chatlog__message-group { - border-color: #ffffff0a; + border-color: rgba(255, 255, 255, 0.1); } .chatlog__author-name { @@ -48,16 +51,16 @@ a { } .chatlog__timestamp { - color: #ffffff33; + color: rgba(255, 255, 255, 0.2); } .chatlog__edited-timestamp { - color: #ffffff33; + color: rgba(255, 255, 255, 0.2); } .chatlog__embed-content-container { - background-color: #2e30364d; - border-color: #2e303699; + background-color: rgba(46, 48, 54, 0.3); + border-color: rgba(46, 48, 54, 0.6); } .chatlog__embed-author-name { @@ -73,7 +76,7 @@ a { } .chatlog__embed-description { - color: #ffffff99; + color: rgba(255, 255, 255, 0.6); } .chatlog__embed-field-name { @@ -81,17 +84,17 @@ a { } .chatlog__embed-field-value { - color: #ffffff99; + color: rgba(255, 255, 255, 0.6); } .chatlog__embed-footer { - color: #ffffff99; + color: rgba(255, 255, 255, 0.6); } .chatlog__reaction { - background-color: #ffffff0a; + background-color: rgba(255, 255, 255, 0.05); } .chatlog__reaction-count { - color: #ffffff4d; + color: rgba(255, 255, 255, 0.3); } \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html deleted file mode 100644 index 84f7a1b..0000000 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight.html +++ /dev/null @@ -1,7 +0,0 @@ -{{ - $SharedStyleSheet = include "Html.Shared.css" - $ThemeStyleSheet = include "Html.LightTheme.css" - StyleSheet = $SharedStyleSheet + "\n" + $ThemeStyleSheet -}} - -{{ include "Html.Core.html" }} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Template.html b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Template.html new file mode 100644 index 0000000..c867d8b --- /dev/null +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Template.html @@ -0,0 +1,2 @@ +{{~ ThemeStyleSheet = include "HtmlLight.Theme.css" ~}} +{{~ include "HtmlShared.Main.html" ~}} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/LightTheme.css b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Theme.css similarity index 81% rename from DiscordChatExporter.Core/Resources/ExportTemplates/Html/LightTheme.css rename to DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Theme.css index 97fec84..c5082f3 100644 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/LightTheme.css +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlLight/Theme.css @@ -2,13 +2,17 @@ body { background-color: #ffffff; - color: #737f8d; + color: #747f8d; } a { color: #00b0f4; } +.spoiler { + background-color: rgba(0, 0, 0, 0.1); +} + .pre { background-color: #f9f9f9; } @@ -56,8 +60,8 @@ a { } .chatlog__embed-content-container { - background-color: #f9f9f94d; - border-color: #cccccc4d; + background-color: rgba(249, 249, 249, 0.3); + border-color: rgba(204, 204, 204, 0.3); } .chatlog__embed-author-name { @@ -85,11 +89,11 @@ a { } .chatlog__embed-footer { - color: #4f535b99; + color: rgba(79, 83, 91, 0.4); } .chatlog__reaction { - background-color: #4f545c0f; + background-color: rgba(79, 84, 92, 0.06); } .chatlog__reaction-count { diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Shared.css b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.css similarity index 96% rename from DiscordChatExporter.Core/Resources/ExportTemplates/Html/Shared.css rename to DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.css index 2c7b393..6228a1a 100644 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Shared.css +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.css @@ -17,9 +17,17 @@ img { object-fit: contain; } +.markdown { + white-space: pre-wrap; + line-height: 1.3; +} + +.spoiler { + border-radius: 3px; +} + .pre { font-family: "Consolas", "Courier New", Courier, Monospace; - white-space: pre-wrap; } .pre--multiline { @@ -34,6 +42,10 @@ img { border-radius: 3px; } +.mention { + font-weight: 500; +} + .emoji { width: 24px; height: 24px; @@ -51,10 +63,6 @@ img { height: 32px; } -.mention { - font-weight: 600; -} - /* === INFO === */ .info { @@ -130,6 +138,7 @@ img { .chatlog__author-name { font-size: 1em; + font-weight: 500; } .chatlog__timestamp { @@ -144,7 +153,7 @@ img { } .chatlog__edited-timestamp { - margin-left: 5px; + margin-left: 3px; font-size: .8em; } diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Core.html b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.html similarity index 92% rename from DiscordChatExporter.Core/Resources/ExportTemplates/Html/Core.html rename to DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.html index 5b888ea..74a2817 100644 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/Html/Core.html +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/HtmlShared/Main.html @@ -6,7 +6,10 @@ + @@ -58,7 +61,7 @@ {{~ # Content ~}} {{~ if message.Content ~}}
- {{ message.Content | FormatContent }} + {{ message.Content | FormatMarkdown }} {{~ # Edited timestamp ~}} {{~ if message.EditedTimestamp ~}} @@ -85,7 +88,7 @@ {{~ # Embeds ~}} {{~ for embed in message.Embeds ~}}
-
+
@@ -112,16 +115,16 @@ {{~ if embed.Title ~}}
{{~ if embed.Url ~}} - {{ embed.Title | FormatContent }} + {{ embed.Title | FormatMarkdown }} {{~ else ~}} - {{ embed.Title | FormatContent }} + {{ embed.Title | FormatMarkdown }} {{~ end ~}}
{{~ end ~}} {{~ # Description ~}} {{~ if embed.Description ~}} -
{{ embed.Description | FormatContent true }}
+
{{ embed.Description | FormatMarkdown }}
{{~ end ~}} {{~ # Fields ~}} @@ -130,10 +133,10 @@ {{~ for field in embed.Fields ~}}
{{~ if field.Name ~}} -
{{ field.Name | FormatContent }}
+
{{ field.Name | FormatMarkdown }}
{{~ end ~}} {{~ if field.Value ~}} -
{{ field.Value | FormatContent true }}
+
{{ field.Value | FormatMarkdown }}
{{~ end ~}}
{{~ end ~}} diff --git a/DiscordChatExporter.Core/Resources/ExportTemplates/PlainText.txt b/DiscordChatExporter.Core/Resources/ExportTemplates/PlainText/Template.txt similarity index 93% rename from DiscordChatExporter.Core/Resources/ExportTemplates/PlainText.txt rename to DiscordChatExporter.Core/Resources/ExportTemplates/PlainText/Template.txt index b190cd9..fb0b90e 100644 --- a/DiscordChatExporter.Core/Resources/ExportTemplates/PlainText.txt +++ b/DiscordChatExporter.Core/Resources/ExportTemplates/PlainText/Template.txt @@ -12,7 +12,7 @@ Range: {{ if Model.From }}{{ Model.From | FormatDate }} {{ end }}{{ if Model. {{~ # Author name and timestamp ~}} {{~ }}[{{ message.Timestamp | FormatDate }}] {{ message.Author.FullName }} {{~ # Content ~}} - {{~ message.Content | FormatContent }} + {{~ message.Content | FormatMarkdown }} {{~ # Attachments ~}} {{~ for attachment in message.Attachments ~}} {{~ attachment.Url }} diff --git a/DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs b/DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs index 62f0675..0bd4c6e 100644 --- a/DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs +++ b/DiscordChatExporter.Core/Services/ExportService.TemplateLoader.cs @@ -20,7 +20,7 @@ namespace DiscordChatExporter.Core.Services public string GetPath(ExportFormat format) { - return $"{ResourceRootNamespace}.{format}.{format.GetFileExtension()}"; + return $"{ResourceRootNamespace}.{format}.Template.{format.GetFileExtension()}"; } public string Load(TemplateContext context, SourceSpan callerSpan, string templatePath) diff --git a/DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs b/DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs index 10f997c..f6a5824 100644 --- a/DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs +++ b/DiscordChatExporter.Core/Services/ExportService.TemplateModel.cs @@ -1,11 +1,10 @@ using System; using System.Collections.Generic; -using System.Drawing; using System.Globalization; using System.Linq; -using System.Net; -using System.Text.RegularExpressions; +using System.Text; using DiscordChatExporter.Core.Internal; +using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Models; using Scriban.Runtime; using Tyrrrz.Extensions; @@ -73,8 +72,6 @@ namespace DiscordChatExporter.Core.Services } } - private string HtmlEncode(string str) => WebUtility.HtmlEncode(str); - private string Format(IFormattable obj, string format) => obj.ToString(format, CultureInfo.InvariantCulture); @@ -95,254 +92,150 @@ namespace DiscordChatExporter.Core.Services return $"{size:0.#} {units[unit]}"; } - private string FormatColor(Color color) + private string FormatMarkdownPlainText(IEnumerable nodes) { - return $"{color.R},{color.G},{color.B},{color.A}"; - } - - private string FormatContentPlainText(string content) - { - // New lines - content = content.Replace("\n", Environment.NewLine); - - // User mentions (<@id> and <@!id>) - var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); + var buffer = new StringBuilder(); - foreach (var mentionedUserId in mentionedUserIds) + foreach (var node in nodes) { - var mentionedUser = _log.Mentionables.GetUser(mentionedUserId); - content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}"); - } - - // Channel mentions (<#id>) - var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); + if (node is FormattedNode formattedNode) + { + var innerText = FormatMarkdownPlainText(formattedNode.Children); + buffer.Append($"{formattedNode.Token}{innerText}{formattedNode.Token}"); + } - foreach (var mentionedChannelId in mentionedChannelIds) - { - var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId); - content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}"); - } + 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}"); + } + } - // Role mentions (<@&id>) - var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); + else if (node is EmojiNode emojiNode) + { + buffer.Append($":{emojiNode.Name}:"); + } - foreach (var mentionedRoleId in mentionedRoleIds) - { - var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId); - content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}"); + else + { + buffer.Append(node.Lexeme); + } } - // Custom emojis (<:name:id>) - content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); - - return content; + return buffer.ToString(); } - private string FormatContentHtml(string content, bool allowLinks = false) - { - // HTML-encode content - content = HtmlEncode(content); - - // Encode multiline codeblocks (```text```) - content = Regex.Replace(content, - @"```+(?:[^`]*?\n)?([^`]+)\n?```+", - m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM"); + private string FormatMarkdownPlainText(string input) + => FormatMarkdownPlainText(MarkdownParser.Parse(input)); - // Encode inline codeblocks (`text`) - content = Regex.Replace(content, - @"`([^`]+)`", - m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI"); + private string FormatMarkdownHtml(IEnumerable nodes) + { + var buffer = new StringBuilder(); - // Encode links - if (allowLinks) + foreach (var node in nodes) { - content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)", - m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL"); - } - - // Encode URLs - content = Regex.Replace(content, - @"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))", - m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU"); - - // Process bold (**text**) - content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "$2"); - - // Process underline (__text__) - content = Regex.Replace(content, @"(__)(?=\S)(.+?)(?<=\S)\1", "$2"); - - // Process italic (*text* or _text_) - content = Regex.Replace(content, @"(\*|_)(?=\S)(.+?)(?<=\S)\1", "$2"); + if (node is TextNode textNode) + { + buffer.Append(textNode.Text.HtmlEncode()); + } - // Process strike through (~~text~~) - content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "$2"); + else if (node is FormattedNode formattedNode) + { + var innerHtml = FormatMarkdownHtml(formattedNode.Children); - // Decode and process multiline codeblocks - content = Regex.Replace(content, "\x1AM(.*?)\x1AM", - m => $"
{m.Groups[1].Value.Base64Decode()}
"); + if (formattedNode.Formatting == TextFormatting.Bold) + buffer.Append($"{innerHtml}"); - // Decode and process inline codeblocks - content = Regex.Replace(content, "\x1AI(.*?)\x1AI", - m => $"{m.Groups[1].Value.Base64Decode()}"); + else if (formattedNode.Formatting == TextFormatting.Italic) + buffer.Append($"{innerHtml}"); - // Decode and process links - if (allowLinks) - { - content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL", - m => $"{m.Groups[1].Value.Base64Decode()}"); - } + else if (formattedNode.Formatting == TextFormatting.Underline) + buffer.Append($"{innerHtml}"); - // Decode and process URLs - content = Regex.Replace(content, "\x1AU(.*?)\x1AU", - m => $"{m.Groups[1].Value.Base64Decode()}"); + else if (formattedNode.Formatting == TextFormatting.Strikethrough) + buffer.Append($"{innerHtml}"); - // Process new lines - content = content.Replace("\n", "
"); - - // Meta mentions (@everyone) - content = content.Replace("@everyone", "@everyone"); - - // Meta mentions (@here) - content = content.Replace("@here", "@here"); + else if (formattedNode.Formatting == TextFormatting.Spoiler) + buffer.Append($"{innerHtml}"); + } - // User mentions (<@id> and <@!id>) - var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); + else if (node is InlineCodeBlockNode inlineCodeBlockNode) + { + buffer.Append($"{inlineCodeBlockNode.Code.HtmlEncode()}"); + } - foreach (var mentionedUserId in mentionedUserIds) - { - var mentionedUser = _log.Mentionables.GetUser(mentionedUserId); - content = Regex.Replace(content, $"<@!?{mentionedUserId}>", - $"" + - $"@{HtmlEncode(mentionedUser.Name)}" + - ""); - } + else if (node is MultilineCodeBlockNode multilineCodeBlockNode) + { + var languageCssClass = multilineCodeBlockNode.Language.IsNotBlank() + ? "language-" + multilineCodeBlockNode.Language + : null; - // Channel mentions (<#id>) - var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); + buffer.Append( + $"
{multilineCodeBlockNode.Code.HtmlEncode()}
"); + } - foreach (var mentionedChannelId in mentionedChannelIds) - { - var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId); - content = content.Replace($"<#{mentionedChannelId}>", - "" + - $"#{HtmlEncode(mentionedChannel.Name)}" + - ""); - } + else if (node is MentionNode mentionNode) + { + if (mentionNode.Type == MentionType.Meta) + { + buffer.Append($"@{mentionNode.Id.HtmlEncode()}"); + } + + else if (mentionNode.Type == MentionType.User) + { + var user = _log.Mentionables.GetUser(mentionNode.Id); + buffer.Append($"@{user.Name.HtmlEncode()}"); + } + + else if (mentionNode.Type == MentionType.Channel) + { + var channel = _log.Mentionables.GetChannel(mentionNode.Id); + buffer.Append($"#{channel.Name.HtmlEncode()}"); + } + + else if (mentionNode.Type == MentionType.Role) + { + var role = _log.Mentionables.GetRole(mentionNode.Id); + buffer.Append($"@{role.Name.HtmlEncode()}"); + } + } - // Role mentions (<@&id>) - var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); + else if (node is EmojiNode emojiNode) + { + buffer.Append($""); + } - foreach (var mentionedRoleId in mentionedRoleIds) - { - var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId); - content = content.Replace($"<@&{mentionedRoleId}>", - "" + - $"@{HtmlEncode(mentionedRole.Name)}" + - ""); + else if (node is LinkNode linkNode) + { + buffer.Append($"{linkNode.Title.HtmlEncode()}"); + } } - // Custom emojis (<:name:id>) - var isJumboable = Regex.Replace(content, "<(:.*?:)(\\d*)>", "").IsBlank(); - var emojiClass = isJumboable ? "emoji emoji--large" : "emoji"; - content = Regex.Replace(content, "<(:.*?:)(\\d*)>", - $""); - - return content; + return buffer.ToString(); } - private string FormatContentCsv(string content) - { - // Escape quotes - content = content.Replace("\"", "\"\""); - - // Escape commas and semicolons - if (content.Contains(",") || content.Contains(";")) - content = $"\"{content}\""; - - // User mentions (<@id> and <@!id>) - var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); + private string FormatMarkdownHtml(string input) + => FormatMarkdownHtml(MarkdownParser.Parse(input)); - foreach (var mentionedUserId in mentionedUserIds) - { - var mentionedUser = _log.Mentionables.GetUser(mentionedUserId); - content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}"); - } - - // Channel mentions (<#id>) - var mentionedChannelIds = Regex.Matches(content, "<#(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); - - foreach (var mentionedChannelId in mentionedChannelIds) - { - var mentionedChannel = _log.Mentionables.GetChannel(mentionedChannelId); - content = content.Replace($"<#{mentionedChannelId}>", $"#{mentionedChannel.Name}"); - } - - // Role mentions (<@&id>) - var mentionedRoleIds = Regex.Matches(content, "<@&(\\d+)>") - .Cast() - .Select(m => m.Groups[1].Value) - .ExceptBlank() - .ToArray(); - - foreach (var mentionedRoleId in mentionedRoleIds) - { - var mentionedRole = _log.Mentionables.GetRole(mentionedRoleId); - content = content.Replace($"<@&{mentionedRoleId}>", $"@{mentionedRole.Name}"); - } - - // Custom emojis (<:name:id>) - content = Regex.Replace(content, "<(:.*?:)\\d*>", "$1"); - - return content; - } - - private string FormatContent(string content, bool allowLinks = false) + private string FormatMarkdown(string input) { - if (_format == ExportFormat.PlainText) - return FormatContentPlainText(content); - - if (_format == ExportFormat.HtmlDark) - return FormatContentHtml(content, allowLinks); - - if (_format == ExportFormat.HtmlLight) - return FormatContentHtml(content, allowLinks); - - if (_format == ExportFormat.Csv) - return FormatContentCsv(content); - - throw new ArgumentOutOfRangeException(nameof(_format)); + return _format == ExportFormat.HtmlDark || _format == ExportFormat.HtmlLight + ? FormatMarkdownHtml(input) + : FormatMarkdownPlainText(input); } public ScriptObject GetScriptObject() @@ -350,7 +243,7 @@ namespace DiscordChatExporter.Core.Services // Create instance var scriptObject = new ScriptObject(); - // Import chat log + // Import model scriptObject.SetValue("Model", _log, true); // Import functions @@ -358,8 +251,7 @@ namespace DiscordChatExporter.Core.Services scriptObject.Import(nameof(Format), new Func(Format)); scriptObject.Import(nameof(FormatDate), new Func(FormatDate)); scriptObject.Import(nameof(FormatFileSize), new Func(FormatFileSize)); - scriptObject.Import(nameof(FormatColor), new Func(FormatColor)); - scriptObject.Import(nameof(FormatContent), new Func(FormatContent)); + scriptObject.Import(nameof(FormatMarkdown), new Func(FormatMarkdown)); return scriptObject; } diff --git a/DiscordChatExporter.sln b/DiscordChatExporter.sln index 81d24ed..1d8bca8 100644 --- a/DiscordChatExporter.sln +++ b/DiscordChatExporter.sln @@ -16,6 +16,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Core", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DiscordChatExporter.Cli", "DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj", "{D08624B6-3081-4BCB-91F8-E9832FACC6CE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Core.Markdown", "DiscordChatExporter.Core.Markdown\DiscordChatExporter.Core.Markdown.csproj", "{14D02A08-E820-4012-B805-663B9A3D73E9}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -34,6 +36,10 @@ Global {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.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.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE