diff --git a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroup.cs b/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroup.cs deleted file mode 100644 index 3762d428..00000000 --- a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroup.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using DiscordChatExporter.Core.Discord.Data; - -namespace DiscordChatExporter.Core.Exporting.Writers.Html; - -// Used for grouping contiguous messages in HTML export -internal partial class MessageGroup -{ - public User Author { get; } - - public DateTimeOffset Timestamp { get; } - - public IReadOnlyList Messages { get; } - - public MessageReference? Reference { get; } - - public Message? ReferencedMessage {get; } - - public MessageGroup( - User author, - DateTimeOffset timestamp, - MessageReference? reference, - Message? referencedMessage, - IReadOnlyList messages) - { - Author = author; - Timestamp = timestamp; - Reference = reference; - ReferencedMessage = referencedMessage; - Messages = messages; - } -} - -internal partial class MessageGroup -{ - public static bool CanJoin(Message message1, Message message2) => - // Must be from the same author - message1.Author.Id == message2.Author.Id && - // Author's name must not have changed between messages - string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) && - // Duration between messages must be 7 minutes or less - (message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 && - // Other message must not be a reply - message2.Reference is null; - - public static MessageGroup Join(IReadOnlyList messages) - { - var first = messages.First(); - - return new MessageGroup( - first.Author, - first.Timestamp, - first.Reference, - first.ReferencedMessage, - messages - ); - } -} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml index ab29e15e..068ef974 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml @@ -3,11 +3,14 @@ @using System.Threading.Tasks @using DiscordChatExporter.Core.Discord.Data @using DiscordChatExporter.Core.Exporting.Writers.Html; +@using DiscordChatExporter.Core.Utils.Extensions @namespace DiscordChatExporter.Core.Exporting.Writers.Html @inherits MiniRazor.TemplateBase @{ + var firstMessage = Model.Messages.First(); + ValueTask ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url); string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); @@ -16,113 +19,128 @@ string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false); - var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id); + var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id); - var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id); + var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id); - var userColorStyle = userColor is not null - ? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})" + var userNick = firstMessage.Author.IsBot + ? firstMessage.Author.Name + : userMember?.Nick ?? firstMessage.Author.Name; + + var referencedUserMember = firstMessage.ReferencedMessage is not null + ? Model.ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id) : null; - var userNick = Model.MessageGroup.Author.IsBot - ? Model.MessageGroup.Author.Name - : userMember?.Nick ?? Model.MessageGroup.Author.Name; - - var referencedUserMember = Model.MessageGroup.ReferencedMessage is not null - ? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id) + var referencedUserColor = firstMessage.ReferencedMessage is not null + ? Model.ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id) : null; - var referencedUserColor = Model.MessageGroup.ReferencedMessage is not null - ? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id) - : null; - - var referencedUserColorStyle = referencedUserColor is not null - ? $"color: rgb({referencedUserColor?.R},{referencedUserColor?.G},{referencedUserColor?.B})" - : null; - - var referencedUserNick = Model.MessageGroup.ReferencedMessage is not null - ? Model.MessageGroup.ReferencedMessage.Author.IsBot - ? Model.MessageGroup.ReferencedMessage.Author.Name - : referencedUserMember?.Nick ?? Model.MessageGroup.ReferencedMessage.Author.Name + var referencedUserNick = firstMessage.ReferencedMessage is not null + ? firstMessage.ReferencedMessage.Author.IsBot + ? firstMessage.ReferencedMessage.Author.Name + : referencedUserMember?.Nick ?? firstMessage.ReferencedMessage.Author.Name : null; }
- @{/* Referenced message */} - @if (Model.MessageGroup.Reference is not null) - { -
-
- @if (Model.MessageGroup.ReferencedMessage is not null) - { - Avatar - @referencedUserNick -
- - @if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content)) - { - @Raw(FormatEmbedMarkdown(Model.MessageGroup.ReferencedMessage.Content)) - } - else if (Model.MessageGroup.ReferencedMessage.Attachments.Any() || Model.MessageGroup.ReferencedMessage.Embeds.Any()) - { - Click to see attachment 🖼️ - } - else - { - Click to see original message - } - +@for (var i = 0; i < Model.Messages.Count; i++) +{ + var isFirst = i == 0; + var message = Model.Messages[i]; - @if (Model.MessageGroup.ReferencedMessage.EditedTimestamp is not null) - { - (edited) - } -
- } - else - { - - Original message was deleted or could not be loaded. - - } -
- } - - @{/* Avatar */} -
- Avatar -
- -
- @{/* Author name */} - @userNick - - @{/* Bot tag */} - @if (Model.MessageGroup.Author.IsBot) - { - BOT - } - - @{/* Message timestamp */} - @FormatDate(Model.MessageGroup.Timestamp) - - @{/* Messages in a group */} - @foreach (var message in Model.MessageGroup.Messages) - { -
- @if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null) +
+
+ @{/* Left side */} +
+ @if (isFirst) { -
-
- @{/* Message content */} - @Raw(FormatMarkdown(message.Content)) + // Reference symbol + if (message.Reference is not null) + { +
+ } - @{/* Edit timestamp */} - @if (message.EditedTimestamp is not null) + // Avatar + Avatar + } + else + { +
@message.Timestamp.ToLocalString("t")
+ } +
+ + @{/* Right side */} +
+ @if (isFirst) + { + // Reference + if (message.Reference is not null) + { +
+ @if (message.ReferencedMessage is not null) { - (edited) + Avatar +
@referencedUserNick
+
+ + @if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content)) + { + @Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content)) + } + else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any()) + { + Click to see attachment + 🖼️ + } + else + { + Click to see original message + } + + + @if (message.ReferencedMessage.EditedTimestamp is not null) + { + (edited) + } +
+ } + else + { +
+ Original message was deleted or could not be loaded. +
}
+ } + + // Header +
+ @{/* Author name */} + @userNick + + @{/* Bot label */} + @if (message.Author.IsBot) + { + BOT + } + + @{/* Timestamp */} + @FormatDate(message.Timestamp) +
+ } + + @{/* Content */} + @if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null) + { +
+ @{/* Text */} + @Raw(FormatMarkdown(message.Content)) + + @{/* Edited timestamp */} + @if (message.EditedTimestamp is not null) + { + (edited) + }
} @@ -159,7 +177,7 @@ {
- + } @@ -270,7 +288,7 @@ @{/* Color pill */} @if (embed.Color is not null) { -
+
} else { @@ -291,10 +309,10 @@ @if (!string.IsNullOrWhiteSpace(embed.Author.Name)) { - + @if (!string.IsNullOrWhiteSpace(embed.Author.Url)) { - @embed.Author.Name + @embed.Author.Name } else { @@ -312,12 +330,12 @@ @if (!string.IsNullOrWhiteSpace(embed.Url)) { -
@Raw(FormatEmbedMarkdown(embed.Title))
+
@Raw(FormatEmbedMarkdown(embed.Title))
} else { -
@Raw(FormatEmbedMarkdown(embed.Title))
+
@Raw(FormatEmbedMarkdown(embed.Title))
}
} @@ -326,7 +344,7 @@ @if (!string.IsNullOrWhiteSpace(embed.Description)) {
-
@Raw(FormatEmbedMarkdown(embed.Description))
+
@Raw(FormatEmbedMarkdown(embed.Description))
} @@ -340,14 +358,14 @@ @if (!string.IsNullOrWhiteSpace(field.Name)) {
-
@Raw(FormatEmbedMarkdown(field.Name))
+
@Raw(FormatEmbedMarkdown(field.Name))
} @if (!string.IsNullOrWhiteSpace(field.Value)) {
-
@Raw(FormatEmbedMarkdown(field.Value))
+
@Raw(FormatEmbedMarkdown(field.Value))
}
@@ -434,13 +452,14 @@ @foreach (var reaction in message.Reactions) {
- @reaction.Emoji.Name + @reaction.Emoji.Name @reaction.Count
}
}
- } +
+}
\ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplateContext.cs b/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplateContext.cs index 8c3eadb9..33955423 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplateContext.cs +++ b/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplateContext.cs @@ -1,4 +1,6 @@ -using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors; +using System.Collections.Generic; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors; namespace DiscordChatExporter.Core.Exporting.Writers.Html; @@ -6,12 +8,12 @@ internal class MessageGroupTemplateContext { public ExportContext ExportContext { get; } - public MessageGroup MessageGroup { get; } + public IReadOnlyList Messages { get; } - public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup) + public MessageGroupTemplateContext(ExportContext exportContext, IReadOnlyList messages) { ExportContext = exportContext; - MessageGroup = messageGroup; + Messages = messages; } public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) => diff --git a/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml index b3abb2a8..b5f43787 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml @@ -32,36 +32,38 @@ @{/* Styling */} @{/* Syntax highlighting */} - - + + @{/* Lottie animation support */} - + @{/* Icons */} - - - - - + + + + + @@ -760,22 +785,22 @@ @if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null) {
- @if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null) - { - @($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") - } - else if (Model.ExportContext.Request.After is not null) - { - @($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}") - } - else if (Model.ExportContext.Request.Before is not null) - { - @($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") - } + @if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null) + { + @($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") + } + else if (Model.ExportContext.Request.After is not null) + { + @($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}") + } + else if (Model.ExportContext.Request.Before is not null) + { + @($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") + }
}
@{/* Preamble cuts off at this point */} -
+
\ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/Writers/HtmlMessageWriter.cs b/DiscordChatExporter.Core/Exporting/Writers/HtmlMessageWriter.cs index 02dc2772..0ede4b5a 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/HtmlMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/Writers/HtmlMessageWriter.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; @@ -13,7 +14,7 @@ internal class HtmlMessageWriter : MessageWriter private readonly TextWriter _writer; private readonly string _themeName; - private readonly List _messageGroupBuffer = new(); + private readonly List _messageGroup = new(); public HtmlMessageWriter(Stream stream, ExportContext context, string themeName) : base(stream, context) @@ -22,6 +23,23 @@ internal class HtmlMessageWriter : MessageWriter _themeName = themeName; } + private bool CanJoinGroup(Message message) + { + var lastMessage = _messageGroup.LastOrDefault(); + if (lastMessage is null) + return true; + + return + // Must be from the same author + lastMessage.Author.Id == message.Author.Id && + // Author's name must not have changed between messages + string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) && + // Duration between messages must be 7 minutes or less + (message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 && + // Other message must not be a reply + message.Reference is null; + } + public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) { var templateContext = new PreambleTemplateContext(Context, _themeName); @@ -34,10 +52,10 @@ internal class HtmlMessageWriter : MessageWriter } private async ValueTask WriteMessageGroupAsync( - MessageGroup messageGroup, + IReadOnlyList messages, CancellationToken cancellationToken = default) { - var templateContext = new MessageGroupTemplateContext(Context, messageGroup); + var templateContext = new MessageGroupTemplateContext(Context, messages); // We are not writing directly to output because Razor // does not actually do asynchronous writes to stream. @@ -52,31 +70,26 @@ internal class HtmlMessageWriter : MessageWriter { await base.WriteMessageAsync(message, cancellationToken); - // If message group is empty or the given message can be grouped, buffer the given message - if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message)) + // If the message can be grouped, buffer it for now + if (CanJoinGroup( message)) { - _messageGroupBuffer.Add(message); + _messageGroup.Add(message); } // Otherwise, flush the group and render messages else { - await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken); + await WriteMessageGroupAsync(_messageGroup, cancellationToken); - _messageGroupBuffer.Clear(); - _messageGroupBuffer.Add(message); + _messageGroup.Clear(); + _messageGroup.Add(message); } } public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) { // Flush current message group - if (_messageGroupBuffer.Any()) - { - await WriteMessageGroupAsync( - MessageGroup.Join(_messageGroupBuffer), - cancellationToken - ); - } + if (_messageGroup.Any()) + await WriteMessageGroupAsync(_messageGroup, cancellationToken); var templateContext = new PostambleTemplateContext(Context, MessagesWritten); diff --git a/DiscordChatExporter.Core/Exporting/Writers/MarkdownVisitors/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/Writers/MarkdownVisitors/HtmlMarkdownVisitor.cs index 86fedf7d..020a9761 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/MarkdownVisitors/HtmlMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/Writers/MarkdownVisitors/HtmlMarkdownVisitor.cs @@ -33,15 +33,37 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor { var (tagOpen, tagClose) = formatting.Kind switch { - FormattingKind.Bold => ("", ""), - FormattingKind.Italic => ("", ""), - FormattingKind.Underline => ("", ""), - FormattingKind.Strikethrough => ("", ""), + FormattingKind.Bold => ( + "", + "" + ), + + FormattingKind.Italic => ( + "", + "" + ), + + FormattingKind.Underline => ( + "", + "" + ), + + FormattingKind.Strikethrough => ( + "", + "" + ), + FormattingKind.Spoiler => ( - "", ""), + "", + "" + ), + FormattingKind.Quote => ( - "
", "
"), - _ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind)) + "
", + "
" + ), + + _ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.") }; _buffer.Append(tagOpen); @@ -54,7 +76,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) { _buffer - .Append("") + .Append("") .Append(HtmlEncode(inlineCodeBlock.Code)) .Append(""); @@ -68,7 +90,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor : "nohighlight"; _buffer - .Append($"
") + .Append($"
") .Append(HtmlEncode(multiLineCodeBlock.Code)) .Append("
"); @@ -98,10 +120,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor protected override MarkdownNode VisitEmoji(EmojiNode emoji) { var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); - var jumboClass = _isJumbo ? "emoji--large" : ""; + var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; _buffer - .Append($"\"{emoji.Name}\""); + .Append($"\"{emoji.Name}\""); return base.VisitEmoji(emoji); } @@ -111,14 +133,14 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor if (mention.Kind == MentionKind.Everyone) { _buffer - .Append("") + .Append("") .Append("@everyone") .Append(""); } else if (mention.Kind == MentionKind.Here) { _buffer - .Append("") + .Append("") .Append("@here") .Append(""); } @@ -129,7 +151,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor var nick = member?.Nick ?? "Unknown"; _buffer - .Append($"") + .Append($"") .Append('@').Append(HtmlEncode(nick)) .Append(""); } @@ -140,7 +162,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor var name = channel?.Name ?? "deleted-channel"; _buffer - .Append("") + .Append("") .Append(symbol).Append(HtmlEncode(name)) .Append(""); } @@ -151,11 +173,12 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor var color = role?.Color; var style = color is not null - ? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);" + ? $"color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); " + + $"background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);" : ""; _buffer - .Append($"") + .Append($"") .Append('@').Append(HtmlEncode(name)) .Append(""); } @@ -175,7 +198,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor : "Invalid date"; _buffer - .Append($"") + .Append($"") .Append(HtmlEncode(dateString)) .Append(""); diff --git a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs index 7ee1c887..9cdab340 100644 --- a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs +++ b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs @@ -77,8 +77,9 @@ internal static partial class MarkdownParser // Capture any character until the end of the line // Opening 'greater than' character must be followed by whitespace + // Text content is optional private static readonly IMatcher SingleLineQuoteNodeMatcher = new RegexMatcher( - new Regex("^>\\s(.+\n?)", DefaultRegexOptions), + new Regex("^>\\s(.*\n?)", DefaultRegexOptions), (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) ); @@ -86,7 +87,7 @@ internal static partial class MarkdownParser // This one is tricky as it ends up producing multiple separate captures which need to be joined private static readonly IMatcher RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher( - new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions), + new Regex("(?:^>\\s(.*\n?)){2,}", DefaultRegexOptions), (_, m) => { var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));