Streaming exporter

Fixes #125
Closes #177
This commit is contained in:
Alexey Golub 2019-12-07 18:43:24 +02:00
parent fc38afe6a0
commit 2a223599f9
44 changed files with 1132 additions and 1098 deletions

View file

@ -1,123 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public class CsvChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _dateFormat;
public CsvChatLogRenderer(ChatLog chatLog, string dateFormat)
{
_chatLog = chatLog;
_dateFormat = dateFormat;
}
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private string FormatMarkdown(Node node)
{
// Text node
if (node is TextNode textNode)
{
return textNode.Text;
}
// Mention node
if (node is MentionNode mentionNode)
{
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return mentionNode.Id;
}
// 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}";
}
}
// Emoji node
if (node is EmojiNode emojiNode)
{
return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
}
// Throw on unexpected nodes
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.ParseMinimal(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 ID
await RenderFieldAsync(writer, message.Author.Id);
// 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);
// Reactions
var formattedReactions = message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(",");
await RenderFieldAsync(writer, formattedReactions);
// Line break
await writer.WriteLineAsync();
}
public async Task RenderAsync(TextWriter writer)
{
// Headers
await writer.WriteLineAsync("AuthorID;Author;Date;Content;Attachments;Reactions;");
// Log
foreach (var message in _chatLog.Messages)
await RenderMessageAsync(writer, message);
}
}
}

View file

@ -0,0 +1,28 @@
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Rendering.Logic;
namespace DiscordChatExporter.Core.Rendering
{
public class CsvMessageRenderer : MessageRendererBase
{
private bool _isHeaderRendered;
public CsvMessageRenderer(string filePath, RenderContext context)
: base(filePath, context)
{
}
public override async Task RenderMessageAsync(Message message)
{
// Render header if it's the first entry
if (!_isHeaderRendered)
{
await Writer.WriteLineAsync(CsvRenderingLogic.FormatHeader(Context));
_isHeaderRendered = true;
}
await Writer.WriteLineAsync(CsvRenderingLogic.FormatMessage(Context, message));
}
}
}

View file

@ -6,12 +6,11 @@
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources\HtmlCore.css" />
<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" />
<EmbeddedResource Include="Resources\HtmlLayoutTemplate.html" />
<EmbeddedResource Include="Resources\HtmlMessageGroupTemplate.html" />
</ItemGroup>
<ItemGroup>

View file

@ -1,25 +0,0 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlChatLogRenderer
{
private class MessageGroup
{
public User Author { get; }
public DateTimeOffset Timestamp { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTimeOffset timestamp, IReadOnlyList<Message> messages)
{
Author = author;
Timestamp = timestamp;
Messages = messages;
}
}
}
}

View file

@ -1,27 +0,0 @@
using System.Reflection;
using System.Threading.Tasks;
using Scriban;
using Scriban.Parsing;
using Scriban.Runtime;
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,169 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Rendering.Logic;
using Scriban;
using Scriban.Runtime;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public partial class HtmlMessageRenderer : MessageRendererBase
{
private readonly string _themeName;
private readonly List<Message> _messageGroupBuffer = new List<Message>();
private bool _isLeadingBlockRendered;
public HtmlMessageRenderer(string filePath, RenderContext context, string themeName)
: base(filePath, context)
{
_themeName = themeName;
}
private MessageGroup GetCurrentMessageGroup()
{
var firstMessage = _messageGroupBuffer.First();
return new MessageGroup(firstMessage.Author, firstMessage.Timestamp, _messageGroupBuffer);
}
private async Task RenderLeadingBlockAsync()
{
var template = Template.Parse(GetLeadingBlockTemplateCode());
var templateContext = CreateTemplateContext();
var scriptObject = CreateScriptObject(Context, _themeName);
templateContext.PushGlobal(scriptObject);
templateContext.PushOutput(new TextWriterOutput(Writer));
await templateContext.EvaluateAsync(template.Page);
}
private async Task RenderTrailingBlockAsync()
{
var template = Template.Parse(GetTrailingBlockTemplateCode());
var templateContext = CreateTemplateContext();
var scriptObject = CreateScriptObject(Context, _themeName);
templateContext.PushGlobal(scriptObject);
templateContext.PushOutput(new TextWriterOutput(Writer));
await templateContext.EvaluateAsync(template.Page);
}
private async Task RenderCurrentMessageGroupAsync()
{
var template = Template.Parse(GetMessageGroupTemplateCode());
var templateContext = CreateTemplateContext();
var scriptObject = CreateScriptObject(Context, _themeName);
scriptObject.SetValue("MessageGroup", GetCurrentMessageGroup(), true);
templateContext.PushGlobal(scriptObject);
templateContext.PushOutput(new TextWriterOutput(Writer));
await templateContext.EvaluateAsync(template.Page);
}
public override async Task RenderMessageAsync(Message message)
{
// Render leading block if it's the first entry
if (!_isLeadingBlockRendered)
{
await RenderLeadingBlockAsync();
_isLeadingBlockRendered = true;
}
// If message group is empty or the given message can be grouped, buffer the given message
if (!_messageGroupBuffer.Any() || HtmlRenderingLogic.CanBeGrouped(_messageGroupBuffer.Last(), message))
{
_messageGroupBuffer.Add(message);
}
// Otherwise, flush the group and render messages
else
{
await RenderCurrentMessageGroupAsync();
_messageGroupBuffer.Clear();
_messageGroupBuffer.Add(message);
}
}
public override async ValueTask DisposeAsync()
{
// Leading block (can happen if no message were rendered)
if (!_isLeadingBlockRendered)
await RenderLeadingBlockAsync();
// Flush current message group
if (_messageGroupBuffer.Any())
await RenderCurrentMessageGroupAsync();
// Trailing block
await RenderTrailingBlockAsync();
await base.DisposeAsync();
}
}
public partial class HtmlMessageRenderer
{
private static readonly Assembly ResourcesAssembly = typeof(HtmlRenderingLogic).Assembly;
private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Resources";
private static string GetCoreStyleSheetCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlCore.css");
private static string GetThemeStyleSheetCode(string themeName) =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.Html{themeName}.css");
private static string GetLeadingBlockTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
.SubstringUntil("{{~ %SPLIT% ~}}");
private static string GetTrailingBlockTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlLayoutTemplate.html")
.SubstringAfter("{{~ %SPLIT% ~}}");
private static string GetMessageGroupTemplateCode() =>
ResourcesAssembly
.GetManifestResourceString($"{ResourcesNamespace}.HtmlMessageGroupTemplate.html");
private static ScriptObject CreateScriptObject(RenderContext context, string themeName)
{
var scriptObject = new ScriptObject();
// Constants
scriptObject.SetValue("Context", context, true);
scriptObject.SetValue("CoreStyleSheet", GetCoreStyleSheetCode(), true);
scriptObject.SetValue("ThemeStyleSheet", GetThemeStyleSheetCode(themeName), true);
scriptObject.SetValue("HighlightJsStyleName", $"solarized-{themeName.ToLowerInvariant()}", true);
// Functions
scriptObject.Import("FormatDate",
new Func<DateTimeOffset, string>(d => SharedRenderingLogic.FormatDate(d, context.DateFormat)));
scriptObject.Import("FormatMarkdown",
new Func<string, string>(m => HtmlRenderingLogic.FormatMarkdown(context, m)));
return scriptObject;
}
private static TemplateContext CreateTemplateContext() =>
new TemplateContext
{
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
}
}

View file

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

View file

@ -0,0 +1,11 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Rendering
{
public interface IMessageRenderer : IAsyncDisposable
{
Task RenderMessageAsync(Message message);
}
}

View file

@ -0,0 +1,21 @@
using System.Text;
namespace DiscordChatExporter.Core.Rendering.Internal
{
internal static class Extensions
{
public static StringBuilder AppendLineIfNotEmpty(this StringBuilder builder, string value) =>
!string.IsNullOrWhiteSpace(value) ? builder.AppendLine(value) : builder;
public static StringBuilder Trim(this StringBuilder builder)
{
while (builder.Length > 0 && char.IsWhiteSpace(builder[0]))
builder.Remove(0, 1);
while (builder.Length > 0 && char.IsWhiteSpace(builder[^1]))
builder.Remove(builder.Length - 1, 1);
return builder;
}
}
}

View file

@ -0,0 +1,39 @@
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
using static DiscordChatExporter.Core.Rendering.Logic.SharedRenderingLogic;
namespace DiscordChatExporter.Core.Rendering.Logic
{
public static class CsvRenderingLogic
{
// Header is always the same
public static string FormatHeader(RenderContext context) => "AuthorID,Author,Date,Content,Attachments,Reactions";
private static string EncodeValue(string value)
{
value = value.Replace("\"", "\"\"");
return $"\"{value}\"";
}
public static string FormatMarkdown(RenderContext context, string markdown) =>
PlainTextRenderingLogic.FormatMarkdown(context, markdown);
public static string FormatMessage(RenderContext context, Message message)
{
var buffer = new StringBuilder();
buffer
.Append(EncodeValue(message.Author.Id)).Append(',')
.Append(EncodeValue(message.Author.FullName)).Append(',')
.Append(EncodeValue(FormatDate(message.Timestamp, context.DateFormat))).Append(',')
.Append(EncodeValue(FormatMarkdown(context, message.Content ?? ""))).Append(',')
.Append(EncodeValue(message.Attachments.Select(a => a.Url).JoinToString(","))).Append(',')
.Append(EncodeValue(message.Reactions.Select(r => $"{r.Emoji.Name} ({r.Count})").JoinToString(",")));
return buffer.ToString();
}
}
}

View file

@ -1,53 +1,31 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
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
namespace DiscordChatExporter.Core.Rendering.Logic
{
public partial class HtmlChatLogRenderer : IChatLogRenderer
internal static class HtmlRenderingLogic
{
private readonly ChatLog _chatLog;
private readonly string _themeName;
private readonly string _dateFormat;
public HtmlChatLogRenderer(ChatLog chatLog, string themeName, string dateFormat)
public static bool CanBeGrouped(Message message1, Message message2)
{
_chatLog = chatLog;
_themeName = themeName;
_dateFormat = dateFormat;
if (message1.Author.Id != message2.Author.Id)
return false;
if ((message2.Timestamp - message1.Timestamp).Duration().TotalMinutes > 7)
return false;
return true;
}
private string HtmlEncode(string s) => WebUtility.HtmlEncode(s);
private static string HtmlEncode(string s) => WebUtility.HtmlEncode(s);
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages) =>
messages.GroupContiguous((buffer, message) =>
{
// Break group if the author changed
if (buffer.Last().Author.Id != message.Author.Id)
return false;
// Break group if last message was more than 7 minutes ago
if ((message.Timestamp - buffer.Last().Timestamp).TotalMinutes > 7)
return false;
return true;
}).Select(g => new MessageGroup(g.First().Author, g.First().Timestamp, g));
private string FormatMarkdown(Node node, bool isJumbo)
private static string FormatMarkdownNode(RenderContext context, Node node, bool isJumbo)
{
// Text node
if (node is TextNode textNode)
@ -60,7 +38,7 @@ namespace DiscordChatExporter.Core.Rendering
if (node is FormattedNode formattedNode)
{
// Recursively get inner html
var innerHtml = FormatMarkdown(formattedNode.Children, false);
var innerHtml = FormatMarkdownNodes(context, formattedNode.Children, false);
// Bold
if (formattedNode.Formatting == TextFormatting.Bold)
@ -116,21 +94,27 @@ namespace DiscordChatExporter.Core.Rendering
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = _chatLog.Mentionables.GetUser(mentionNode.Id);
var user = context.MentionableUsers.FirstOrDefault(u => u.Id == mentionNode.Id) ??
User.CreateUnknownUser(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);
var channel = context.MentionableChannels.FirstOrDefault(c => c.Id == mentionNode.Id) ??
Channel.CreateDeletedChannel(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);
var role = context.MentionableRoles.FirstOrDefault(r => r.Id == mentionNode.Id) ??
Role.CreateDeletedRole(mentionNode.Id);
return $"<span class=\"mention\">@{HtmlEncode(role.Name)}</span>";
}
}
@ -159,52 +143,18 @@ namespace DiscordChatExporter.Core.Rendering
}
// Throw on unexpected nodes
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
throw new InvalidOperationException($"Unexpected node [{node.GetType()}].");
}
private string FormatMarkdown(IReadOnlyList<Node> nodes, bool isTopLevel)
private static string FormatMarkdownNodes(RenderContext context, IReadOnlyList<Node> nodes, bool isTopLevel)
{
// Emojis are jumbo if all top-level nodes are emoji nodes or whitespace text nodes
var isJumbo = isTopLevel && nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
return nodes.Select(n => FormatMarkdown(n, isJumbo)).JoinToString("");
return nodes.Select(n => FormatMarkdownNode(context, n, isJumbo)).JoinToString("");
}
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.Parse(markdown), true);
public async Task RenderAsync(TextWriter writer)
{
// Create template loader
var loader = new TemplateLoader();
// Get template
var templateCode = loader.Load($"Html{_themeName}.html");
var template = Template.Parse(templateCode);
// Create template context
var context = new TemplateContext
{
TemplateLoader = loader,
MemberRenamer = m => m.Name,
MemberFilter = m => true,
LoopLimit = int.MaxValue,
StrictVariables = true
};
// Create template model
var model = new ScriptObject();
model.SetValue("Model", _chatLog, true);
model.Import(nameof(GroupMessages), new Func<IEnumerable<Message>, IEnumerable<MessageGroup>>(GroupMessages));
model.Import(nameof(FormatDate), new Func<DateTimeOffset, string>(FormatDate));
model.Import(nameof(FormatMarkdown), new Func<string, string>(FormatMarkdown));
context.PushGlobal(model);
// Configure output
context.PushOutput(new TextWriterOutput(writer));
// HACK: Render output in a separate thread
// (even though Scriban has async API, it still makes a lot of blocking CPU-bound calls)
await Task.Run(async () => await context.EvaluateAsync(template.Page));
}
public static string FormatMarkdown(RenderContext context, string markdown) =>
FormatMarkdownNodes(context, MarkdownParser.Parse(markdown), true);
}
}

View file

@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Rendering.Internal;
using Tyrrrz.Extensions;
using static DiscordChatExporter.Core.Rendering.Logic.SharedRenderingLogic;
namespace DiscordChatExporter.Core.Rendering.Logic
{
public static class PlainTextRenderingLogic
{
public static string FormatPreamble(RenderContext context)
{
var buffer = new StringBuilder();
buffer.AppendLine('='.Repeat(62));
buffer.AppendLine($"Guild: {context.Guild.Name}");
buffer.AppendLine($"Channel: {context.Channel.Name}");
if (!string.IsNullOrWhiteSpace(context.Channel.Topic))
buffer.AppendLine($"Topic: {context.Channel.Topic}");
if (context.After != null)
buffer.AppendLine($"After: {FormatDate(context.After.Value, context.DateFormat)}");
if (context.Before != null)
buffer.AppendLine($"Before: {FormatDate(context.Before.Value, context.DateFormat)}");
buffer.AppendLine('='.Repeat(62));
return buffer.ToString();
}
private static string FormatMarkdownNode(RenderContext context, Node node)
{
// Text node
if (node is TextNode textNode)
{
return textNode.Text;
}
// Mention node
if (node is MentionNode mentionNode)
{
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return $"@{mentionNode.Id}";
}
// User mention node
if (mentionNode.Type == MentionType.User)
{
var user = context.MentionableUsers.FirstOrDefault(u => u.Id == mentionNode.Id) ??
User.CreateUnknownUser(mentionNode.Id);
return $"@{user.Name}";
}
// Channel mention node
if (mentionNode.Type == MentionType.Channel)
{
var channel = context.MentionableChannels.FirstOrDefault(c => c.Id == mentionNode.Id) ??
Channel.CreateDeletedChannel(mentionNode.Id);
return $"#{channel.Name}";
}
// Role mention node
if (mentionNode.Type == MentionType.Role)
{
var role = context.MentionableRoles.FirstOrDefault(r => r.Id == mentionNode.Id) ??
Role.CreateDeletedRole(mentionNode.Id);
return $"@{role.Name}";
}
}
// Emoji node
if (node is EmojiNode emojiNode)
{
return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
}
// Throw on unexpected nodes
throw new InvalidOperationException($"Unexpected node [{node.GetType()}].");
}
public static string FormatMarkdown(RenderContext context, string markdown) =>
MarkdownParser.ParseMinimal(markdown).Select(n => FormatMarkdownNode(context, n)).JoinToString("");
public static string FormatMessageHeader(RenderContext context, Message message)
{
var buffer = new StringBuilder();
// Timestamp & author
buffer
.Append($"[{FormatDate(message.Timestamp, context.DateFormat)}]")
.Append(' ')
.Append($"{message.Author.FullName}");
// Whether the message is pinned
if (message.IsPinned)
{
buffer.Append(' ').Append("(pinned)");
}
return buffer.ToString();
}
public static string FormatMessageContent(RenderContext context, Message message)
{
if (string.IsNullOrWhiteSpace(message.Content))
return "";
return FormatMarkdown(context, message.Content);
}
public static string FormatAttachments(RenderContext context, IReadOnlyList<Attachment> attachments)
{
if (!attachments.Any())
return "";
var buffer = new StringBuilder();
buffer
.AppendLine("{Attachments}")
.AppendJoin(Environment.NewLine, attachments.Select(a => a.Url))
.AppendLine();
return buffer.ToString();
}
public static string FormatEmbeds(RenderContext context, IReadOnlyList<Embed> embeds)
{
if (!embeds.Any())
return "";
var buffer = new StringBuilder();
foreach (var embed in embeds)
{
buffer.AppendLine("{Embed}");
// Author name
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
buffer.AppendLine(embed.Author.Name);
// URL
if (!string.IsNullOrWhiteSpace(embed.Url))
buffer.AppendLine(embed.Url);
// Title
if (!string.IsNullOrWhiteSpace(embed.Title))
buffer.AppendLine(FormatMarkdown(context, embed.Title));
// Description
if (!string.IsNullOrWhiteSpace(embed.Description))
buffer.AppendLine(FormatMarkdown(context, embed.Description));
// Fields
foreach (var field in embed.Fields)
{
// Name
if (!string.IsNullOrWhiteSpace(field.Name))
buffer.AppendLine(field.Name);
// Value
if (!string.IsNullOrWhiteSpace(field.Value))
buffer.AppendLine(field.Value);
}
// Thumbnail URL
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
buffer.AppendLine(embed.Thumbnail?.Url);
// Image URL
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
buffer.AppendLine(embed.Image?.Url);
// Footer text
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
buffer.AppendLine(embed.Footer?.Text);
buffer.AppendLine();
}
return buffer.ToString();
}
public static string FormatReactions(RenderContext context, IReadOnlyList<Reaction> reactions)
{
if (!reactions.Any())
return "";
var buffer = new StringBuilder();
buffer.AppendLine("{Reactions}");
foreach (var reaction in reactions)
{
buffer.Append(reaction.Emoji.Name);
if (reaction.Count > 1)
buffer.Append($" ({reaction.Count})");
buffer.Append(" ");
}
buffer.AppendLine();
return buffer.ToString();
}
public static string FormatMessage(RenderContext context, Message message)
{
var buffer = new StringBuilder();
buffer
.AppendLine(FormatMessageHeader(context, message))
.AppendLineIfNotEmpty(FormatMessageContent(context, message))
.AppendLine()
.AppendLineIfNotEmpty(FormatAttachments(context, message.Attachments))
.AppendLineIfNotEmpty(FormatEmbeds(context, message.Embeds))
.AppendLineIfNotEmpty(FormatReactions(context, message.Reactions));
return buffer.Trim().ToString();
}
}
}

View file

@ -0,0 +1,11 @@
using System;
using System.Globalization;
namespace DiscordChatExporter.Core.Rendering.Logic
{
public static class SharedRenderingLogic
{
public static string FormatDate(DateTimeOffset date, string dateFormat) =>
date.ToLocalTime().ToString(dateFormat, CultureInfo.InvariantCulture);
}
}

View file

@ -0,0 +1,23 @@
using System.IO;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Rendering
{
public abstract class MessageRendererBase : IMessageRenderer
{
protected TextWriter Writer { get; }
protected RenderContext Context { get; }
protected MessageRendererBase(string filePath, RenderContext context)
{
Writer = File.CreateText(filePath);
Context = context;
}
public abstract Task RenderMessageAsync(Message message);
public virtual ValueTask DisposeAsync() => Writer.DisposeAsync();
}
}

View file

@ -1,237 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Nodes;
using DiscordChatExporter.Core.Models;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Core.Rendering
{
public class PlainTextChatLogRenderer : IChatLogRenderer
{
private readonly ChatLog _chatLog;
private readonly string _dateFormat;
public PlainTextChatLogRenderer(ChatLog chatLog, string dateFormat)
{
_chatLog = chatLog;
_dateFormat = dateFormat;
}
private string FormatDate(DateTimeOffset date) =>
date.ToLocalTime().ToString(_dateFormat, CultureInfo.InvariantCulture);
private string FormatDateRange(DateTimeOffset? after, DateTimeOffset? before)
{
// Both 'after' and 'before'
if (after != null && before != null)
return $"{FormatDate(after.Value)} to {FormatDate(before.Value)}";
// Just 'after'
if (after != null)
return $"after {FormatDate(after.Value)}";
// Just 'before'
if (before != null)
return $"before {FormatDate(before.Value)}";
// Neither
return "";
}
private string FormatMarkdown(Node node)
{
// Text node
if (node is TextNode textNode)
{
return textNode.Text;
}
// Mention node
if (node is MentionNode mentionNode)
{
// Meta mention node
if (mentionNode.Type == MentionType.Meta)
{
return mentionNode.Id;
}
// 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}";
}
}
// Emoji node
if (node is EmojiNode emojiNode)
{
return emojiNode.IsCustomEmoji ? $":{emojiNode.Name}:" : emojiNode.Name;
}
// Throw on unexpected nodes
throw new InvalidOperationException($"Unexpected node: [{node.GetType()}].");
}
private string FormatMarkdown(IEnumerable<Node> nodes) => nodes.Select(FormatMarkdown).JoinToString("");
private string FormatMarkdown(string markdown) => FormatMarkdown(MarkdownParser.ParseMinimal(markdown));
private async Task RenderMessageHeaderAsync(TextWriter writer, Message message)
{
// Timestamp
await writer.WriteAsync($"[{FormatDate(message.Timestamp)}]");
// Author
await writer.WriteAsync($" {message.Author.FullName}");
// Whether the message is pinned
if (message.IsPinned)
await writer.WriteAsync(" (pinned)");
await writer.WriteLineAsync();
}
private async Task RenderAttachmentsAsync(TextWriter writer, IReadOnlyList<Attachment> attachments)
{
if (attachments.Any())
{
await writer.WriteLineAsync("{Attachments}");
foreach (var attachment in attachments)
await writer.WriteLineAsync(attachment.Url);
await writer.WriteLineAsync();
}
}
private async Task RenderEmbedsAsync(TextWriter writer, IReadOnlyList<Embed> embeds)
{
foreach (var embed in embeds)
{
await writer.WriteLineAsync("{Embed}");
// Author name
if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
await writer.WriteLineAsync(embed.Author?.Name);
// URL
if (!string.IsNullOrWhiteSpace(embed.Url))
await writer.WriteLineAsync(embed.Url);
// Title
if (!string.IsNullOrWhiteSpace(embed.Title))
await writer.WriteLineAsync(FormatMarkdown(embed.Title));
// Description
if (!string.IsNullOrWhiteSpace(embed.Description))
await writer.WriteLineAsync(FormatMarkdown(embed.Description));
// Fields
foreach (var field in embed.Fields)
{
// Name
if (!string.IsNullOrWhiteSpace(field.Name))
await writer.WriteLineAsync(field.Name);
// Value
if (!string.IsNullOrWhiteSpace(field.Value))
await writer.WriteLineAsync(field.Value);
}
// Thumbnail URL
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
await writer.WriteLineAsync(embed.Thumbnail?.Url);
// Image URL
if (!string.IsNullOrWhiteSpace(embed.Image?.Url))
await writer.WriteLineAsync(embed.Image?.Url);
// Footer text
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
await writer.WriteLineAsync(embed.Footer?.Text);
await writer.WriteLineAsync();
}
}
private async Task RenderReactionsAsync(TextWriter writer, IReadOnlyList<Reaction> reactions)
{
if (reactions.Any())
{
await writer.WriteLineAsync("{Reactions}");
foreach (var reaction in reactions)
{
await writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1)
await writer.WriteAsync($" ({reaction.Count})");
await writer.WriteAsync(" ");
}
await writer.WriteLineAsync();
await writer.WriteLineAsync();
}
}
private async Task RenderMessageAsync(TextWriter writer, Message message)
{
// Header
await RenderMessageHeaderAsync(writer, message);
// Content
if (!string.IsNullOrWhiteSpace(message.Content))
await writer.WriteLineAsync(FormatMarkdown(message.Content));
// Separator
await writer.WriteLineAsync();
// Attachments
await RenderAttachmentsAsync(writer, message.Attachments);
// Embeds
await RenderEmbedsAsync(writer, message.Embeds);
// Reactions
await RenderReactionsAsync(writer, message.Reactions);
}
public async Task RenderAsync(TextWriter writer)
{
// Metadata
await writer.WriteLineAsync('='.Repeat(62));
await writer.WriteLineAsync($"Guild: {_chatLog.Guild.Name}");
await writer.WriteLineAsync($"Channel: {_chatLog.Channel.Name}");
await writer.WriteLineAsync($"Topic: {_chatLog.Channel.Topic}");
await writer.WriteLineAsync($"Messages: {_chatLog.Messages.Count:N0}");
await writer.WriteLineAsync($"Range: {FormatDateRange(_chatLog.After, _chatLog.Before)}");
await writer.WriteLineAsync('='.Repeat(62));
await writer.WriteLineAsync();
// Log
foreach (var message in _chatLog.Messages)
await RenderMessageAsync(writer, message);
}
}
}

View file

@ -0,0 +1,29 @@
using System.Threading.Tasks;
using DiscordChatExporter.Core.Models;
using DiscordChatExporter.Core.Rendering.Logic;
namespace DiscordChatExporter.Core.Rendering
{
public class PlainTextMessageRenderer : MessageRendererBase
{
private bool _isPreambleRendered;
public PlainTextMessageRenderer(string filePath, RenderContext context)
: base(filePath, context)
{
}
public override async Task RenderMessageAsync(Message message)
{
// Render preamble if it's the first entry
if (!_isPreambleRendered)
{
await Writer.WriteLineAsync(PlainTextRenderingLogic.FormatPreamble(Context));
_isPreambleRendered = true;
}
await Writer.WriteLineAsync(PlainTextRenderingLogic.FormatMessage(Context, message));
await Writer.WriteLineAsync();
}
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Models;
namespace DiscordChatExporter.Core.Rendering
{
public class RenderContext
{
public Guild Guild { get; }
public Channel Channel { get; }
public DateTimeOffset? After { get; }
public DateTimeOffset? Before { get; }
public string DateFormat { get; }
public IReadOnlyCollection<User> MentionableUsers { get; }
public IReadOnlyCollection<Channel> MentionableChannels { get; }
public IReadOnlyCollection<Role> MentionableRoles { get; }
public RenderContext(Guild guild, Channel channel, DateTimeOffset? after, DateTimeOffset? before, string dateFormat,
IReadOnlyCollection<User> mentionableUsers, IReadOnlyCollection<Channel> mentionableChannels, IReadOnlyCollection<Role> mentionableRoles)
{
Guild = guild;
Channel = channel;
After = after;
Before = before;
DateFormat = dateFormat;
MentionableUsers = mentionableUsers;
MentionableChannels = mentionableChannels;
MentionableRoles = mentionableRoles;
}
}
}

View file

@ -358,6 +358,7 @@ img {
background: #7289da;
color: #ffffff;
font-size: 0.625em;
font-weight: 500;
padding: 1px 2px;
border-radius: 3px;
vertical-align: middle;

View file

@ -23,7 +23,7 @@ a {
.pre--multiline {
border-color: #282b30 !important;
color: #839496 !important;
color: #b9bbbe !important;
}
.mention {

View file

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

View file

@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{~ # Metadata ~}}
<title>{{ Context.Guild.Name | html.escape }} - {{ Context.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
{{~ # Styles ~}}
<style>
{{ CoreStyleSheet }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
});
</script>
{{~ # Local scripts ~}}
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
if (element) {
event.preventDefault();
element.classList.add('chatlog__message--highlighted');
window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
}
</script>
</head>
<body>
{{~ # Info ~}}
<div class="info">
<div class="info__guild-icon-container">
<img class="info__guild-icon" src="{{ Context.Guild.IconUrl }}" />
</div>
<div class="info__metadata">
<div class="info__guild-name">{{ Context.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Context.Channel.Name | html.escape }}</div>
{{~ if Context.Channel.Topic ~}}
<div class="info__channel-topic">{{ Context.Channel.Topic | html.escape }}</div>
{{~ end ~}}
{{~ if Context.After || Context.Before ~}}
<div class="info__channel-date-range">
{{~ if Context.After && Context.Before ~}}
Between {{ Context.After | FormatDate | html.escape }} and {{ Context.Before | FormatDate | html.escape }}
{{~ else if Context.After ~}}
After {{ Context.After | FormatDate | html.escape }}
{{~ else if Context.Before ~}}
Before {{ Context.Before | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ # Log ~}}
<div class="chatlog">
{{~ %SPLIT% ~}}
</div>
</body>
</html>

View file

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

View file

@ -0,0 +1,170 @@
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ MessageGroup.Author.AvatarUrl }}" />
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}">{{ MessageGroup.Author.Name | html.escape }}</span>
{{~ # Bot tag ~}}
{{~ if MessageGroup.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span>
{{~ end ~}}
<span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in MessageGroup.Messages ~}}
<div class="chatlog__message {{if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
<span class="markdown">{{ message.Content | FormatMarkdown }}</span>
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
<a href="{{ attachment.Url }}">
{{ # Image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
</div>
{{~ end ~}}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
{{~ if embed.Color ~}}
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
{{~ else ~}}
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
{{~ end ~}}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
{{~ end ~}}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
{{~ else ~}}
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
</a>
</div>
{{~ end ~}}
</div>
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
</a>
</div>
{{~ end ~}}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{~ end ~}}
{{~ end ~}}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}" />
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>

View file

@ -1,259 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
{{~ # Metadata ~}}
<title>{{ Model.Guild.Name | html.escape }} - {{ Model.Channel.Name | html.escape }}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
{{~ # Styles ~}}
<style>
{{ include "HtmlShared.css" }}
</style>
<style>
{{ ThemeStyleSheet }}
</style>
{{~ # Syntax highlighting ~}}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.pre--multiline').forEach((block) => {
hljs.highlightBlock(block);
});
});
</script>
{{~ # Local scripts ~}}
<script>
function scrollToMessage(event, id) {
var element = document.getElementById('message-' + id);
if (element) {
event.preventDefault();
element.classList.add('chatlog__message--highlighted');
window.scrollTo({
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
behavior: 'smooth'
});
window.setTimeout(function() {
element.classList.remove('chatlog__message--highlighted');
}, 2000);
}
}
</script>
</head>
<body>
{{~ # Info ~}}
<div class="info">
<div class="info__guild-icon-container">
<img class="info__guild-icon" src="{{ Model.Guild.IconUrl }}" />
</div>
<div class="info__metadata">
<div class="info__guild-name">{{ Model.Guild.Name | html.escape }}</div>
<div class="info__channel-name">{{ Model.Channel.Name | html.escape }}</div>
{{~ if Model.Channel.Topic ~}}
<div class="info__channel-topic">{{ Model.Channel.Topic | html.escape }}</div>
{{~ end ~}}
<div class="info__channel-message-count">{{ Model.Messages | array.size | object.format "N0" }} messages</div>
{{~ if Model.After || Model.Before ~}}
<div class="info__channel-date-range">
{{~ if Model.After && Model.Before ~}}
Between {{ Model.After | FormatDate | html.escape }} and {{ Model.Before | FormatDate | html.escape }}
{{~ else if Model.After ~}}
After {{ Model.After | FormatDate | html.escape }}
{{~ else if Model.Before ~}}
Before {{ Model.Before | FormatDate | html.escape }}
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ # Log ~}}
<div class="chatlog">
{{~ for group in Model.Messages | GroupMessages ~}}
<div class="chatlog__message-group">
{{~ # Avatar ~}}
<div class="chatlog__author-avatar-container">
<img class="chatlog__author-avatar" src="{{ group.Author.AvatarUrl }}" />
</div>
<div class="chatlog__messages">
{{~ # Author name and timestamp ~}}
<span class="chatlog__author-name" title="{{ group.Author.FullName | html.escape }}" data-user-id="{{ group.Author.Id | html.escape }}">{{ group.Author.Name | html.escape }}</span>
{{~ # Bot tag ~}}
{{~ if group.Author.IsBot ~}}
<span class="chatlog__bot-tag">BOT</span>
{{~ end ~}}
<span class="chatlog__timestamp">{{ group.Timestamp | FormatDate | html.escape }}</span>
{{~ # Messages ~}}
{{~ for message in group.Messages ~}}
<div class="chatlog__message {{if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
{{~ # Content ~}}
{{~ if message.Content ~}}
<div class="chatlog__content">
<span class="markdown">{{ message.Content | FormatMarkdown }}</span>
{{~ # Edited timestamp ~}}
{{~ if message.EditedTimestamp ~}}
<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Attachments ~}}
{{~ for attachment in message.Attachments ~}}
<div class="chatlog__attachment">
<a href="{{ attachment.Url }}">
{{ # Image }}
{{~ if attachment.IsImage ~}}
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url }}" />
{{~ # Non-image ~}}
{{~ else ~}}
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
{{~ end ~}}
</a>
</div>
{{~ end ~}}
{{~ # Embeds ~}}
{{~ for embed in message.Embeds ~}}
<div class="chatlog__embed">
{{~ if embed.Color ~}}
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
{{~ else ~}}
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
{{~ end ~}}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
{{~ # Author ~}}
{{~ if embed.Author ~}}
<div class="chatlog__embed-author">
{{~ if embed.Author.IconUrl ~}}
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl }}" />
{{~ end ~}}
{{~ if embed.Author.Name ~}}
<span class="chatlog__embed-author-name">
{{~ if embed.Author.Url ~}}
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
{{~ else ~}}
{{ embed.Author.Name | html.escape }}
{{~ end ~}}
</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Title ~}}
{{~ if embed.Title ~}}
<div class="chatlog__embed-title">
{{~ if embed.Url ~}}
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><span class="markdown">{{ embed.Title | FormatMarkdown }}</span></a>
{{~ else ~}}
<span class="markdown">{{ embed.Title | FormatMarkdown }}</span>
{{~ end ~}}
</div>
{{~ end ~}}
{{~ # Description ~}}
{{~ if embed.Description ~}}
<div class="chatlog__embed-description"><span class="markdown">{{ embed.Description | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ # Fields ~}}
{{~ if embed.Fields | array.size > 0 ~}}
<div class="chatlog__embed-fields">
{{~ for field in embed.Fields ~}}
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
{{~ if field.Name ~}}
<div class="chatlog__embed-field-name"><span class="markdown">{{ field.Name | FormatMarkdown }}</span></div>
{{~ end ~}}
{{~ if field.Value ~}}
<div class="chatlog__embed-field-value"><span class="markdown">{{ field.Value | FormatMarkdown }}</span></div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ # Thumbnail ~}}
{{~ if embed.Thumbnail ~}}
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url }}">
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url }}" />
</a>
</div>
{{~ end ~}}
</div>
{{~ # Image ~}}
{{~ if embed.Image ~}}
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url }}">
<img class="chatlog__embed-image" src="{{ embed.Image.Url }}" />
</a>
</div>
{{~ end ~}}
{{~ # Footer ~}}
{{~ if embed.Footer || embed.Timestamp ~}}
<div class="chatlog__embed-footer">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl }}" />
{{~ end ~}}
{{~ end ~}}
<span class="chatlog__embed-footer-text">
{{~ if embed.Footer ~}}
{{~ if embed.Footer.Text ~}}
{{ embed.Footer.Text | html.escape }}
{{ if embed.Timestamp }} • {{ end }}
{{~ end ~}}
{{~ end ~}}
{{~ if embed.Timestamp ~}}
{{ embed.Timestamp | FormatDate | html.escape }}
{{~ end ~}}
</span>
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
{{~ # Reactions ~}}
{{~ if message.Reactions | array.size > 0 ~}}
<div class="chatlog__reactions">
{{~ for reaction in message.Reactions ~}}
<div class="chatlog__reaction">
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl }}" />
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
{{~ end ~}}
</div>
</div>
{{~ end ~}}
</div>
</body>
</html>