@@ -112,16 +115,16 @@
{{~ if embed.Title ~}}
{{~ 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 b190cd93..fb0b90e4 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 62f06750..0bd4c6e3 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 10f997c5..f6a5824e 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}";
+ 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.Name}:");
+ }
+
+ else
+ {
+ buffer.Append(node.Lexeme);
+ }
+ }
+
+ return buffer.ToString();
}
- private string FormatContentPlainText(string content)
+ private string FormatMarkdownPlainText(string input)
+ => FormatMarkdownPlainText(MarkdownParser.Parse(input));
+
+ private string FormatMarkdownHtml(IEnumerable nodes)
{
- // New lines
- content = content.Replace("\n", Environment.NewLine);
+ var buffer = new StringBuilder();
- // User mentions (<@id> and <@!id>)
- var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
- .Cast()
- .Select(m => m.Groups[1].Value)
- .ExceptBlank()
- .ToArray();
-
- foreach (var mentionedUserId in mentionedUserIds)
+ foreach (var node in nodes)
{
- var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
- content = Regex.Replace(content, $"<@!?{mentionedUserId}>", $"@{mentionedUser.FullName}");
+ if (node is TextNode textNode)
+ {
+ buffer.Append(textNode.Text.HtmlEncode());
+ }
+
+ else if (node is FormattedNode formattedNode)
+ {
+ var innerHtml = FormatMarkdownHtml(formattedNode.Children);
+
+ if (formattedNode.Formatting == TextFormatting.Bold)
+ buffer.Append($"{innerHtml}");
+
+ else if (formattedNode.Formatting == TextFormatting.Italic)
+ buffer.Append($"{innerHtml}");
+
+ else if (formattedNode.Formatting == TextFormatting.Underline)
+ buffer.Append($"{innerHtml}");
+
+ else if (formattedNode.Formatting == TextFormatting.Strikethrough)
+ buffer.Append($"{innerHtml}");
+
+ else if (formattedNode.Formatting == TextFormatting.Spoiler)
+ buffer.Append($"{innerHtml}");
+ }
+
+ else if (node is InlineCodeBlockNode inlineCodeBlockNode)
+ {
+ buffer.Append($"{inlineCodeBlockNode.Code.HtmlEncode()}");
+ }
+
+ else if (node is MultilineCodeBlockNode multilineCodeBlockNode)
+ {
+ var languageCssClass = multilineCodeBlockNode.Language.IsNotBlank()
+ ? "language-" + multilineCodeBlockNode.Language
+ : null;
+
+ buffer.Append(
+ $"{multilineCodeBlockNode.Code.HtmlEncode()}
");
+ }
+
+ 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()}");
+ }
+ }
+
+ else if (node is EmojiNode emojiNode)
+ {
+ buffer.Append($"
");
+ }
+
+ else if (node is LinkNode linkNode)
+ {
+ buffer.Append($"{linkNode.Title.HtmlEncode()}");
+ }
}
- // 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;
+ return buffer.ToString();
}
- private string FormatContentHtml(string content, bool allowLinks = false)
+ private string FormatMarkdownHtml(string input)
+ => FormatMarkdownHtml(MarkdownParser.Parse(input));
+
+ private string FormatMarkdown(string input)
{
- // HTML-encode content
- content = HtmlEncode(content);
-
- // Encode multiline codeblocks (```text```)
- content = Regex.Replace(content,
- @"```+(?:[^`]*?\n)?([^`]+)\n?```+",
- m => $"\x1AM{m.Groups[1].Value.Base64Encode()}\x1AM");
-
- // Encode inline codeblocks (`text`)
- content = Regex.Replace(content,
- @"`([^`]+)`",
- m => $"\x1AI{m.Groups[1].Value.Base64Encode()}\x1AI");
-
- // Encode links
- if (allowLinks)
- {
- content = Regex.Replace(content, @"\[(.*?)\]\((.*?)\)",
- m => $"\x1AL{m.Groups[1].Value.Base64Encode()}|{m.Groups[2].Value.Base64Encode()}\x1AL");
- }
-
- // Encode URLs
- content = Regex.Replace(content,
- @"(\b(?:(?:https?|ftp|file)://|www\.|ftp\.)(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];])*(?:\([-a-zA-Z0-9+&@#/%?=~_|!:,\.\[\];]*\)|[-a-zA-Z0-9+&@#/%=~_|$]))",
- m => $"\x1AU{m.Groups[1].Value.Base64Encode()}\x1AU");
-
- // Process bold (**text**)
- content = Regex.Replace(content, @"(\*\*)(?=\S)(.+?[*_]*)(?<=\S)\1", "$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");
-
- // Process strike through (~~text~~)
- content = Regex.Replace(content, @"(~~)(?=\S)(.+?)(?<=\S)\1", "$2");
-
- // Decode and process multiline codeblocks
- content = Regex.Replace(content, "\x1AM(.*?)\x1AM",
- m => $"{m.Groups[1].Value.Base64Decode()}
");
-
- // Decode and process inline codeblocks
- content = Regex.Replace(content, "\x1AI(.*?)\x1AI",
- m => $"{m.Groups[1].Value.Base64Decode()}");
-
- // Decode and process links
- if (allowLinks)
- {
- content = Regex.Replace(content, "\x1AL(.*?)\\|(.*?)\x1AL",
- m => $"{m.Groups[1].Value.Base64Decode()}");
- }
-
- // Decode and process URLs
- content = Regex.Replace(content, "\x1AU(.*?)\x1AU",
- m => $"{m.Groups[1].Value.Base64Decode()}");
-
- // Process new lines
- content = content.Replace("\n", "
");
-
- // Meta mentions (@everyone)
- content = content.Replace("@everyone", "@everyone");
-
- // Meta mentions (@here)
- content = content.Replace("@here", "@here");
-
- // User mentions (<@id> and <@!id>)
- var mentionedUserIds = Regex.Matches(content, "<@!?(\\d+)>")
- .Cast()
- .Select(m => m.Groups[1].Value)
- .ExceptBlank()
- .ToArray();
-
- foreach (var mentionedUserId in mentionedUserIds)
- {
- var mentionedUser = _log.Mentionables.GetUser(mentionedUserId);
- content = Regex.Replace(content, $"<@!?{mentionedUserId}>",
- $"" +
- $"@{HtmlEncode(mentionedUser.Name)}" +
- "");
- }
-
- // 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}>",
- "" +
- $"#{HtmlEncode(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}>",
- "" +
- $"@{HtmlEncode(mentionedRole.Name)}" +
- "");
- }
-
- // 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;
- }
-
- 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();
-
- 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)
- {
- 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 81d24ed0..1d8bca8d 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