Add support for different formats in the timestamp markdown node

Closes #662
This commit is contained in:
Tyrrrz
2023-02-12 16:12:41 +02:00
parent 75b942f66c
commit d99958a9b1
34 changed files with 321 additions and 156 deletions

View File

@@ -19,7 +19,7 @@ public partial record Channel(
string? Topic,
Snowflake? LastMessageId) : IHasId
{
public bool SupportsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
}
public partial record Channel
@@ -92,4 +92,4 @@ public partial record Channel
lastMessageId
);
}
}
}

View File

@@ -18,8 +18,8 @@ public partial record struct Snowflake
{
public static Snowflake Zero { get; } = new(0);
public static Snowflake FromDate(DateTimeOffset date) => new(
((ulong)date.ToUnixTimeMilliseconds() - 1420070400000UL) << 22
public static Snowflake FromDate(DateTimeOffset instant) => new(
((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22
);
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
@@ -34,9 +34,9 @@ public partial record struct Snowflake
}
// As date
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var date))
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var instant))
{
return FromDate(date);
return FromDate(instant);
}
return null;

View File

@@ -50,8 +50,8 @@ internal partial class ExportAssetDownloader
try
{
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)
? date
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
? instant
: (DateTimeOffset?) null
);

View File

@@ -38,11 +38,11 @@ internal class ExportContext
_assetDownloader = new ExportAssetDownloader(request.OutputAssetsDirPath, request.ShouldReuseAssets);
}
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
public string FormatDate(DateTimeOffset instant) => Request.DateFormat switch
{
"unix" => date.ToUnixTimeSeconds().ToString(),
"unixms" => date.ToUnixTimeMilliseconds().ToString(),
var format => date.ToLocalString(format)
"unix" => instant.ToUnixTimeSeconds().ToString(),
"unixms" => instant.ToUnixTimeMilliseconds().ToString(),
var format => instant.ToLocalString(format)
};
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);

View File

@@ -40,33 +40,45 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var (openingTag, closingTag) = formatting.Kind switch
{
FormattingKind.Bold => (
// language=HTML
"<strong>",
// language=HTML
"</strong>"
),
FormattingKind.Italic => (
// language=HTML
"<em>",
// language=HTML
"</em>"
),
FormattingKind.Underline => (
// language=HTML
"<u>",
// language=HTML
"</u>"
),
FormattingKind.Strikethrough => (
// language=HTML
"<s>",
// language=HTML
"</s>"
),
FormattingKind.Spoiler => (
"<span class=\"chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden\" onclick=\"showSpoiler(event, this)\">",
"</span>"
// language=HTML
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
// language=HTML
"""</span>"""
),
FormattingKind.Quote => (
"<div class=\"chatlog__markdown-quote\"><div class=\"chatlog__markdown-quote-border\"></div><div class=\"chatlog__markdown-quote-content\">",
"</div></div>"
// language=HTML
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
// language=HTML
"""</div></div>"""
),
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
@@ -83,10 +95,12 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
InlineCodeBlockNode inlineCodeBlock,
CancellationToken cancellationToken = default)
{
_buffer
.Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
.Append(HtmlEncode(inlineCodeBlock.Code))
.Append("</code>");
_buffer.Append(
// language=HTML
$"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code>
"""
);
return await base.VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken);
}
@@ -95,14 +109,16 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
MultiLineCodeBlockNode multiLineCodeBlock,
CancellationToken cancellationToken = default)
{
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
var highlightClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
? $"language-{multiLineCodeBlock.Language}"
: "nohighlight";
_buffer
.Append($"<code class=\"chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightCssClass}\">")
.Append(HtmlEncode(multiLineCodeBlock.Code))
.Append("</code>");
_buffer.Append(
// language=HTML
$"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code>
"""
);
return await base.VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken);
}
@@ -111,7 +127,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
LinkNode link,
CancellationToken cancellationToken = default)
{
// Try to extract message ID if the link refers to a Discord message
// Try to extract the message ID if the link points to a Discord message
var linkedMessageId = Regex.Match(
link.Url,
"^https?://(?:discord|discordapp).com/channels/.*?/(\\d+)/?$"
@@ -119,11 +135,15 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_buffer.Append(
!string.IsNullOrWhiteSpace(linkedMessageId)
? $"<a href=\"{HtmlEncode(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
: $"<a href=\"{HtmlEncode(link.Url)}\">"
// language=HTML
? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">"""
// language=HTML
: $"""<a href="{HtmlEncode(link.Url)}">"""
);
var result = await base.VisitLinkAsync(link, cancellationToken);
// language=HTML
_buffer.Append("</a>");
return result;
@@ -137,13 +157,15 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
_buffer.Append(
$"<img " +
$"loading=\"lazy\" " +
$"class=\"chatlog__emoji {jumboClass}\" " +
$"alt=\"{emoji.Name}\" " +
$"title=\"{emoji.Code}\" " +
$"src=\"{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}\"" +
$">"
// language=HTML
$"""
<img
loading="lazy"
class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}"
title="{emoji.Code}"
src="{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
"""
);
return await base.VisitEmojiAsync(emoji, cancellationToken);
@@ -155,17 +177,21 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
{
if (mention.Kind == MentionKind.Everyone)
{
_buffer
.Append("<span class=\"chatlog__markdown-mention\">")
.Append("@everyone")
.Append("</span>");
_buffer.Append(
// language=HTML
"""
<span class="chatlog__markdown-mention">@everyone</span>
"""
);
}
else if (mention.Kind == MentionKind.Here)
{
_buffer
.Append("<span class=\"chatlog__markdown-mention\">")
.Append("@here")
.Append("</span>");
_buffer.Append(
// language=HTML
"""
<span class="chatlog__markdown-mention">@here</span>
"""
);
}
else if (mention.Kind == MentionKind.User)
{
@@ -173,21 +199,25 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var fullName = member?.User.FullName ?? "Unknown";
var nick = member?.Nick ?? "Unknown";
_buffer
.Append($"<span class=\"chatlog__markdown-mention\" title=\"{HtmlEncode(fullName)}\">")
.Append('@').Append(HtmlEncode(nick))
.Append("</span>");
_buffer.Append(
// language=HTML
$"""
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(nick)}</span>
"""
);
}
else if (mention.Kind == MentionKind.Channel)
{
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
var symbol = channel?.SupportsVoice == true ? "🔊" : "#";
var symbol = channel?.IsVoice == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel";
_buffer
.Append("<span class=\"chatlog__markdown-mention\">")
.Append(symbol).Append(HtmlEncode(name))
.Append("</span>");
_buffer.Append(
// language=HTML
$"""
<span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span>
"""
);
}
else if (mention.Kind == MentionKind.Role)
{
@@ -196,38 +226,42 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
var color = role?.Color;
var style = color is not null
? $"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);"
? $"""
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($"<span class=\"chatlog__markdown-mention\" style=\"{style}\">")
.Append('@').Append(HtmlEncode(name))
.Append("</span>");
_buffer.Append(
// language=HTML
$"""
<span class="chatlog__markdown-mention" style="{style}">@{HtmlEncode(name)}</span>
"""
);
}
return await base.VisitMentionAsync(mention, cancellationToken);
}
protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(
UnixTimestampNode timestamp,
protected override async ValueTask<MarkdownNode> VisitTimestampAsync(
TimestampNode timestamp,
CancellationToken cancellationToken = default)
{
var dateString = timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
var formatted = timestamp.Instant is not null
? !string.IsNullOrWhiteSpace(timestamp.Format)
? timestamp.Instant.Value.ToLocalString(timestamp.Format)
: _context.FormatDate(timestamp.Instant.Value)
: "Invalid date";
// Timestamp tooltips always use full date regardless of the configured format
var longDateString = timestamp.Date is not null
? timestamp.Date.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt")
: "Invalid date";
var formattedLong = timestamp.Instant?.ToLocalString("dddd, MMMM d, yyyy h:mm tt") ?? "";
_buffer
.Append($"<span class=\"chatlog__markdown-timestamp\" title=\"{HtmlEncode(longDateString)}\">")
.Append(HtmlEncode(dateString))
.Append("</span>");
_buffer.Append(
// language=HTML
$"""
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span>
"""
);
return await base.VisitUnixTimestampAsync(timestamp, cancellationToken);
return await base.VisitTimestampAsync(timestamp, cancellationToken);
}
}
@@ -248,9 +282,7 @@ internal partial class HtmlMarkdownVisitor
nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
var buffer = new StringBuilder();
await new HtmlMarkdownVisitor(context, buffer, isJumbo)
.VisitAsync(nodes, cancellationToken);
await new HtmlMarkdownVisitor(context, buffer, isJumbo).VisitAsync(nodes, cancellationToken);
return buffer.ToString();
}

View File

@@ -18,8 +18,8 @@
ValueTask<string> ResolveAssetUrlAsync(string url) =>
ExportContext.ResolveAssetUrlAsync(url, CancellationToken);
string FormatDate(DateTimeOffset date) =>
ExportContext.FormatDate(date);
string FormatDate(DateTimeOffset instant) =>
ExportContext.FormatDate(instant);
ValueTask<string> FormatMarkdownAsync(string markdown) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown, true, CancellationToken);

View File

@@ -66,7 +66,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"#{name}");
// Voice channel marker
if (channel?.SupportsVoice == true)
if (channel?.IsVoice == true)
_buffer.Append(" [voice]");
}
else if (mention.Kind == MentionKind.Role)
@@ -80,17 +80,19 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
return await base.VisitMentionAsync(mention, cancellationToken);
}
protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(
UnixTimestampNode timestamp,
protected override async ValueTask<MarkdownNode> VisitTimestampAsync(
TimestampNode timestamp,
CancellationToken cancellationToken = default)
{
_buffer.Append(
timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
timestamp.Instant is not null
? !string.IsNullOrWhiteSpace(timestamp.Format)
? timestamp.Instant.Value.ToLocalString(timestamp.Format)
: _context.FormatDate(timestamp.Instant.Value)
: "Invalid date"
);
return await base.VisitUnixTimestampAsync(timestamp, cancellationToken);
return await base.VisitTimestampAsync(timestamp, cancellationToken);
}
}
@@ -102,10 +104,9 @@ internal partial class PlainTextMarkdownVisitor
CancellationToken cancellationToken = default)
{
var nodes = MarkdownParser.ParseMinimal(markdown);
var buffer = new StringBuilder();
await new PlainTextMarkdownVisitor(context, buffer)
.VisitAsync(nodes, cancellationToken);
var buffer = new StringBuilder();
await new PlainTextMarkdownVisitor(context, buffer).VisitAsync(nodes, cancellationToken);
return buffer.ToString();
}

View File

@@ -22,8 +22,8 @@
ValueTask<string> ResolveAssetUrlAsync(string url) =>
ExportContext.ResolveAssetUrlAsync(url, CancellationToken);
string FormatDate(DateTimeOffset date) =>
ExportContext.FormatDate(date);
string FormatDate(DateTimeOffset instant) =>
ExportContext.FormatDate(instant);
ValueTask<string> FormatMarkdownAsync(string markdown) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown, true, CancellationToken);
@@ -660,6 +660,7 @@
.chatlog__markdown-spoiler {
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
padding: 0 2px;
border-radius: 3px;
}
@@ -728,9 +729,9 @@
}
.chatlog__markdown-timestamp {
border-radius: 3px;
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
padding: 0 2px;
color: @Themed("#a3a6aa", "#5e6772");
border-radius: 3px;
}
.chatlog__emoji {

View File

@@ -275,29 +275,37 @@ internal static partial class MarkdownParser
/* Misc */
private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
// Capture <t:12345678> or <t:12345678:R>
new Regex(@"<t:(-?\d+)(?::\w)?>", DefaultRegexOptions),
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
(_, m) =>
{
// TODO: support formatting parameters
// See: https://github.com/Tyrrrz/DiscordChatExporter/issues/662
if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture,
out var offset))
{
return new UnixTimestampNode(null);
}
try
{
return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
var instant = DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(
long.Parse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture)
);
var format = m.Groups[2].Value switch
{
"t" => "h:mm tt",
"T" => "h:mm:ss tt",
"d" => "MM/dd/yyyy",
"D" => "MMMM dd, yyyy",
"f" => "MMMM dd, yyyy h:mm tt",
"F" => "dddd, MMMM dd, yyyy h:mm tt",
// Relative format is ignored because it doesn't make much sense in a static export
_ => null
};
return new TimestampNode(instant, format);
}
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
// https://github.com/Tyrrrz/DiscordChatExporter/issues/766
catch (Exception ex) when (ex is ArgumentOutOfRangeException or OverflowException)
catch (Exception ex) when (ex is FormatException or ArgumentOutOfRangeException or OverflowException)
{
return new UnixTimestampNode(null);
// For invalid timestamps, Discord renders "Invalid Date" instead of ignoring the markdown
return TimestampNode.Invalid;
}
}
);
@@ -346,7 +354,7 @@ internal static partial class MarkdownParser
CodedStandardEmojiNodeMatcher,
// Misc
UnixTimestampNodeMatcher
TimestampNodeMatcher
);
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
@@ -362,7 +370,7 @@ internal static partial class MarkdownParser
CustomEmojiNodeMatcher,
// Misc
UnixTimestampNodeMatcher
TimestampNodeMatcher
);
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) =>

View File

@@ -48,8 +48,8 @@ internal abstract class MarkdownVisitor
CancellationToken cancellationToken = default) =>
new(mention);
protected virtual ValueTask<MarkdownNode> VisitUnixTimestampAsync(
UnixTimestampNode timestamp,
protected virtual ValueTask<MarkdownNode> VisitTimestampAsync(
TimestampNode timestamp,
CancellationToken cancellationToken = default) =>
new(timestamp);
@@ -78,8 +78,8 @@ internal abstract class MarkdownVisitor
MentionNode mention =>
await VisitMentionAsync(mention, cancellationToken),
UnixTimestampNode timestamp =>
await VisitUnixTimestampAsync(timestamp, cancellationToken),
TimestampNode timestamp =>
await VisitTimestampAsync(timestamp, cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(node))
};

View File

@@ -0,0 +1,9 @@
using System;
namespace DiscordChatExporter.Core.Markdown;
// Null date means invalid timestamp
internal record TimestampNode(DateTimeOffset? Instant, string? Format) : MarkdownNode
{
public static TimestampNode Invalid { get; } = new(null, null);
}

View File

@@ -1,6 +0,0 @@
using System;
namespace DiscordChatExporter.Core.Markdown;
// Null date means invalid timestamp
internal record UnixTimestampNode(DateTimeOffset? Date) : MarkdownNode;

View File

@@ -5,6 +5,6 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class DateExtensions
{
public static string ToLocalString(this DateTimeOffset dateTime, string format) =>
dateTime.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
public static string ToLocalString(this DateTimeOffset instant, string format) =>
instant.ToLocalTime().ToString(format, CultureInfo.InvariantCulture);
}