Add support for different formats in the timestamp markdown node

Closes #662
pull/1002/head
Tyrrrz 2 years ago
parent 75b942f66c
commit d99958a9b1

@ -1,6 +1,6 @@
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Tests.TestData; namespace DiscordChatExporter.Cli.Tests.Infra;
public static class ChannelIds public static class ChannelIds
{ {
@ -14,6 +14,8 @@ public static class ChannelIds
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020"); public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794"); public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052"); public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");

@ -1,6 +1,5 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -5,7 +5,6 @@ using System.Threading.Tasks;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands; using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Cli.Tests.Utils; using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;

@ -4,7 +4,6 @@ using System.Threading.Tasks;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands; using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Cli.Tests.Utils; using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;

@ -2,7 +2,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -2,7 +2,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -2,7 +2,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@ -33,7 +32,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_containing_an_image_link_is_rendered_with_an_image_embed() public async Task Message_with_an_image_link_is_rendered_with_an_image_embed()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/537 // https://github.com/Tyrrrz/DiscordChatExporter/issues/537
@ -53,7 +52,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_containing_an_image_link_and_nothing_else_is_rendered_without_text_content() public async Task Message_with_an_image_link_and_nothing_else_is_rendered_without_text_content()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682 // https://github.com/Tyrrrz/DiscordChatExporter/issues/682
@ -69,7 +68,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_containing_a_gifv_link_is_rendered_with_a_video_embed() public async Task Message_with_a_GIFV_link_is_rendered_with_a_video_embed()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -87,7 +86,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_containing_a_gifv_link_and_nothing_else_is_rendered_without_text_content() public async Task Message_with_a_GIFV_link_and_nothing_else_is_rendered_without_text_content()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -101,7 +100,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed() public async Task Message_with_a_Spotify_track_link_is_rendered_with_a_track_embed()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/657 // https://github.com/Tyrrrz/DiscordChatExporter/issues/657
@ -117,7 +116,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_containing_a_YouTube_video_link_is_rendered_with_a_video_embed() public async Task Message_with_a_YouTube_video_link_is_rendered_with_a_video_embed()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/570 // https://github.com/Tyrrrz/DiscordChatExporter/issues/570
@ -133,7 +132,7 @@ public class HtmlEmbedSpecs
} }
[Fact] [Fact]
public async Task Message_containing_a_Twitter_post_link_with_multiple_images_is_rendered_as_a_single_embed() public async Task Message_with_a_Twitter_post_link_with_multiple_images_is_rendered_as_a_single_embed()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695 // https://github.com/Tyrrrz/DiscordChatExporter/issues/695

@ -5,7 +5,6 @@ using AngleSharp.Dom;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands; using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Cli.Tests.Utils; using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using FluentAssertions; using FluentAssertions;

@ -0,0 +1,136 @@
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlMarkdownSpecs
{
[Fact]
public async Task Message_with_a_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323136411078787")
);
// Assert
message.Text().Should().Contain("Default timestamp: 12-Feb-23 03:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_a_short_time_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323205268967596")
);
// Assert
message.Text().Should().Contain("Short time timestamp: 3:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_a_long_time_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323235342139483")
);
// Assert
message.Text().Should().Contain("Long time timestamp: 3:36:12 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_a_short_date_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323326727634984")
);
// Assert
message.Text().Should().Contain("Short date timestamp: 02/12/2023");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_a_long_date_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323350731640863")
);
// Assert
message.Text().Should().Contain("Long date timestamp: February 12, 2023");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_a_full_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323374379118593")
);
// Assert
message.Text().Should().Contain("Full timestamp: February 12, 2023 3:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_a_full_long_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323409095376947")
);
// Assert
message.Text().Should().Contain("Full long timestamp: Sunday, February 12, 2023 3:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_a_relative_timestamp_is_rendered_as_the_default_timestamp()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323436853285004")
);
// Assert
message.Text().Should().Contain("Relative timestamp: 12-Feb-23 03:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
}
[Fact]
public async Task Message_with_an_invalid_timestamp_is_rendered_correctly()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074328534409019563")
);
// Assert
message.Text().Should().Contain("Invalid timestamp: Invalid date");
}
}

@ -1,7 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@ -11,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlMentionSpecs public class HtmlMentionSpecs
{ {
[Fact] [Fact]
public async Task User_mention_is_rendered_correctly() public async Task Message_with_a_user_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -25,7 +24,7 @@ public class HtmlMentionSpecs
} }
[Fact] [Fact]
public async Task Text_channel_mention_is_rendered_correctly() public async Task Message_with_a_text_channel_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -38,7 +37,7 @@ public class HtmlMentionSpecs
} }
[Fact] [Fact]
public async Task Voice_channel_mention_is_rendered_correctly() public async Task Message_with_a_voice_channel_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -51,7 +50,7 @@ public class HtmlMentionSpecs
} }
[Fact] [Fact]
public async Task Role_mention_is_rendered_correctly() public async Task Message_with_a_role_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(

@ -1,7 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@ -11,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlReplySpecs public class HtmlReplySpecs
{ {
[Fact] [Fact]
public async Task Reply_to_a_normal_message_is_rendered_correctly() public async Task Message_with_a_reply_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsHtmlAsync( var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -25,7 +24,7 @@ public class HtmlReplySpecs
} }
[Fact] [Fact]
public async Task Reply_to_a_deleted_message_is_rendered_correctly() public async Task Message_with_a_reply_to_a_deleted_message_is_rendered_correctly()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/645 // https://github.com/Tyrrrz/DiscordChatExporter/issues/645
@ -43,7 +42,7 @@ public class HtmlReplySpecs
} }
[Fact] [Fact]
public async Task Reply_to_an_empty_message_with_attachment_is_rendered_correctly() public async Task Message_with_a_reply_to_an_empty_message_with_attachment_is_rendered_correctly()
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/634 // https://github.com/Tyrrrz/DiscordChatExporter/issues/634

@ -1,6 +1,5 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -1,7 +1,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -1,7 +1,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -1,7 +1,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -1,7 +1,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@ -11,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonMentionSpecs public class JsonMentionSpecs
{ {
[Fact] [Fact]
public async Task User_mention_is_rendered_correctly() public async Task Message_with_a_user_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -31,7 +30,7 @@ public class JsonMentionSpecs
} }
[Fact] [Fact]
public async Task Text_channel_mention_is_rendered_correctly() public async Task Message_with_a_text_channel_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -44,7 +43,7 @@ public class JsonMentionSpecs
} }
[Fact] [Fact]
public async Task Voice_channel_mention_is_rendered_correctly() public async Task Message_with_a_voice_channel_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -57,7 +56,7 @@ public class JsonMentionSpecs
} }
[Fact] [Fact]
public async Task Role_mention_is_rendered_correctly() public async Task Message_with_a_role_mention_is_rendered_correctly()
{ {
// Act // Act
var message = await ExportWrapper.GetMessageAsJsonAsync( var message = await ExportWrapper.GetMessageAsJsonAsync(

@ -1,7 +1,6 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -3,7 +3,6 @@ using System.Threading.Tasks;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands; using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Cli.Tests.Utils; using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;

@ -1,6 +1,5 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;

@ -4,7 +4,6 @@ using System.Threading.Tasks;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands; using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.TestData;
using DiscordChatExporter.Cli.Tests.Utils; using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using FluentAssertions; using FluentAssertions;

@ -19,7 +19,7 @@ public partial record Channel(
string? Topic, string? Topic,
Snowflake? LastMessageId) : IHasId 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 public partial record Channel
@ -92,4 +92,4 @@ public partial record Channel
lastMessageId lastMessageId
); );
} }
} }

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

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

@ -38,11 +38,11 @@ internal class ExportContext
_assetDownloader = new ExportAssetDownloader(request.OutputAssetsDirPath, request.ShouldReuseAssets); _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(), "unix" => instant.ToUnixTimeSeconds().ToString(),
"unixms" => date.ToUnixTimeMilliseconds().ToString(), "unixms" => instant.ToUnixTimeMilliseconds().ToString(),
var format => date.ToLocalString(format) var format => instant.ToLocalString(format)
}; };
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id); public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);

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

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

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

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

@ -275,29 +275,37 @@ internal static partial class MarkdownParser
/* Misc */ /* 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> // Capture <t:12345678> or <t:12345678:R>
new Regex(@"<t:(-?\d+)(?::\w)?>", DefaultRegexOptions), new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
(_, m) => (_, 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 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/681
// https://github.com/Tyrrrz/DiscordChatExporter/issues/766 // 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, CodedStandardEmojiNodeMatcher,
// Misc // Misc
UnixTimestampNodeMatcher TimestampNodeMatcher
); );
// Minimal set of matchers for non-multimedia formats (e.g. plain text) // Minimal set of matchers for non-multimedia formats (e.g. plain text)
@ -362,7 +370,7 @@ internal static partial class MarkdownParser
CustomEmojiNodeMatcher, CustomEmojiNodeMatcher,
// Misc // Misc
UnixTimestampNodeMatcher TimestampNodeMatcher
); );
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) => private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) =>

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

@ -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);
}

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

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

@ -358,10 +358,10 @@
<materialDesign:PackIcon.Style> <materialDesign:PackIcon.Style>
<Style TargetType="{x:Type materialDesign:PackIcon}"> <Style TargetType="{x:Type materialDesign:PackIcon}">
<Style.Triggers> <Style.Triggers>
<DataTrigger Binding="{Binding SupportsVoice}" Value="True"> <DataTrigger Binding="{Binding IsVoice}" Value="True">
<Setter Property="Kind" Value="VolumeHigh" /> <Setter Property="Kind" Value="VolumeHigh" />
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding SupportsVoice}" Value="False"> <DataTrigger Binding="{Binding IsVoice}" Value="False">
<Setter Property="Kind" Value="Pound" /> <Setter Property="Kind" Value="Pound" />
</DataTrigger> </DataTrigger>
</Style.Triggers> </Style.Triggers>

Loading…
Cancel
Save