Download emoji used in messages (#957)

pull/965/head
Roberto Blázquez 2 years ago committed by GitHub
parent 38be44debb
commit 1131f8659d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -19,8 +19,8 @@ internal partial class CsvMessageWriter : MessageWriter
_writer = new StreamWriter(stream);
}
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
@ -84,7 +84,7 @@ internal partial class CsvMessageWriter : MessageWriter
await _writer.WriteAsync(',');
// Message content
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content)));
await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content)));
await _writer.WriteAsync(',');
// Attachments

@ -14,9 +14,9 @@
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
ValueTask<string> FormatMarkdownAsync(string markdown) => Model.FormatMarkdownAsync(markdown);
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
ValueTask<string> FormatEmbedMarkdownAsync(string markdown) => Model.FormatMarkdownAsync(markdown, false);
var firstMessage = Model.Messages.First();
@ -125,7 +125,7 @@
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
{
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore-->
<!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(message.ReferencedMessage.Content))<!--/wmm:ignore-->
}
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
{
@ -176,7 +176,7 @@
@{/* Text */}
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
{
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
}
@{/* Edited timestamp */}
@ -296,12 +296,12 @@
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
</a>
}
else
{
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
}
</div>
}
@ -382,12 +382,12 @@
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
</a>
}
else
{
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div>
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
}
</div>
}
@ -396,7 +396,7 @@
@if (!string.IsNullOrWhiteSpace(embed.Description))
{
<div class="chatlog__embed-description">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Description))<!--/wmm:ignore--></div>
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
</div>
}
@ -410,14 +410,14 @@
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Name))<!--/wmm:ignore--></div>
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(field.Name))<!--/wmm:ignore--></div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Value))<!--/wmm:ignore--></div>
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
</div>
}
</div>

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
@ -16,6 +17,6 @@ internal class MessageGroupTemplateContext
Messages = messages;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
}

@ -18,7 +18,7 @@
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
ValueTask<string> FormatMarkdownAsync(string markdown) => Model.FormatMarkdownAsync(markdown);
}
<!DOCTYPE html>
@ -851,7 +851,7 @@
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic))
{
<div class="preamble__entry preamble__entry--small">@Raw(FormatMarkdown(Model.ExportContext.Request.Channel.Topic))</div>
<div class="preamble__entry preamble__entry--small">@Raw(await FormatMarkdownAsync(Model.ExportContext.Request.Channel.Topic))</div>
}
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)

@ -1,4 +1,5 @@
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
@ -14,6 +15,6 @@ internal class PreambleTemplateContext
ThemeName = themeName;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
}

@ -29,8 +29,8 @@ internal class JsonMessageWriter : MessageWriter
});
}
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
private async ValueTask WriteAttachmentAsync(
Attachment attachment,
@ -100,8 +100,8 @@ internal class JsonMessageWriter : MessageWriter
{
_writer.WriteStartObject();
_writer.WriteString("name", FormatMarkdown(embedField.Name));
_writer.WriteString("value", FormatMarkdown(embedField.Value));
_writer.WriteString("name", await FormatMarkdownAsync(embedField.Name));
_writer.WriteString("value", await FormatMarkdownAsync(embedField.Value));
_writer.WriteBoolean("isInline", embedField.IsInline);
_writer.WriteEndObject();
@ -114,10 +114,10 @@ internal class JsonMessageWriter : MessageWriter
{
_writer.WriteStartObject();
_writer.WriteString("title", FormatMarkdown(embed.Title));
_writer.WriteString("title", await FormatMarkdownAsync(embed.Title));
_writer.WriteString("url", embed.Url);
_writer.WriteString("timestamp", embed.Timestamp);
_writer.WriteString("description", FormatMarkdown(embed.Description));
_writer.WriteString("description", await FormatMarkdownAsync(embed.Description));
if (embed.Color is not null)
_writer.WriteString("color", embed.Color.Value.ToHex());
@ -268,7 +268,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteBoolean("isPinned", message.IsPinned);
// Content
_writer.WriteString("content", FormatMarkdown(message.Content));
_writer.WriteString("content", await FormatMarkdownAsync(message.Content));
// Author
_writer.WriteStartObject("author");

@ -3,6 +3,7 @@ using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
@ -23,13 +24,13 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_isJumbo = isJumbo;
}
protected override MarkdownNode VisitText(TextNode text)
protected override ValueTask<MarkdownNode> VisitTextAsync(TextNode text)
{
_buffer.Append(HtmlEncode(text.Text));
return base.VisitText(text);
return base.VisitTextAsync(text);
}
protected override MarkdownNode VisitFormatting(FormattingNode formatting)
protected override ValueTask<MarkdownNode> VisitFormattingAsync(FormattingNode formatting)
{
var (tagOpen, tagClose) = formatting.Kind switch
{
@ -67,23 +68,23 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
};
_buffer.Append(tagOpen);
var result = base.VisitFormatting(formatting);
var result = base.VisitFormattingAsync(formatting);
_buffer.Append(tagClose);
return result;
}
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
protected override ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock)
{
_buffer
.Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
.Append(HtmlEncode(inlineCodeBlock.Code))
.Append("</code>");
return base.VisitInlineCodeBlock(inlineCodeBlock);
return base.VisitInlineCodeBlockAsync(inlineCodeBlock);
}
protected override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock)
protected override ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(MultiLineCodeBlockNode multiLineCodeBlock)
{
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
? $"language-{multiLineCodeBlock.Language}"
@ -94,10 +95,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append(HtmlEncode(multiLineCodeBlock.Code))
.Append("</code>");
return base.VisitMultiLineCodeBlock(multiLineCodeBlock);
return base.VisitMultiLineCodeBlockAsync(multiLineCodeBlock);
}
protected override MarkdownNode VisitLink(LinkNode link)
protected override ValueTask<MarkdownNode> VisitLinkAsync(LinkNode link)
{
// Try to extract message ID if the link refers to a Discord message
var linkedMessageId = Regex.Match(
@ -111,24 +112,24 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: $"<a href=\"{HtmlEncode(link.Url)}\">"
);
var result = base.VisitLink(link);
var result = base.VisitLinkAsync(link);
_buffer.Append("</a>");
return result;
}
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
protected override async ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji)
{
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
_buffer
.Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
.Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{await _context.ResolveMediaUrlAsync(emojiImageUrl)}\">");
return base.VisitEmoji(emoji);
return await base.VisitEmojiAsync(emoji);
}
protected override MarkdownNode VisitMention(MentionNode mention)
protected override ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention)
{
if (mention.Kind == MentionKind.Everyone)
{
@ -183,10 +184,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append("</span>");
}
return base.VisitMention(mention);
return base.VisitMentionAsync(mention);
}
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
protected override ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp)
{
var dateString = timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value)
@ -202,7 +203,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append(HtmlEncode(dateString))
.Append("</span>");
return base.VisitUnixTimestamp(timestamp);
return base.VisitUnixTimestampAsync(timestamp);
}
}
@ -210,7 +211,7 @@ internal partial class HtmlMarkdownVisitor
{
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true)
public static async ValueTask<string> FormatAsync(ExportContext context, string markdown, bool isJumboAllowed = true)
{
var nodes = MarkdownParser.Parse(markdown);
@ -220,7 +221,7 @@ internal partial class HtmlMarkdownVisitor
var buffer = new StringBuilder();
new HtmlMarkdownVisitor(context, buffer, isJumbo).Visit(nodes);
await new HtmlMarkdownVisitor(context, buffer, isJumbo).VisitAsync(nodes);
return buffer.ToString();
}

@ -1,4 +1,5 @@
using System.Text;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
@ -16,13 +17,13 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer = buffer;
}
protected override MarkdownNode VisitText(TextNode text)
protected override ValueTask<MarkdownNode> VisitTextAsync(TextNode text)
{
_buffer.Append(text.Text);
return base.VisitText(text);
return base.VisitTextAsync(text);
}
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
protected override ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji)
{
_buffer.Append(
emoji.IsCustomEmoji
@ -30,10 +31,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: emoji.Name
);
return base.VisitEmoji(emoji);
return base.VisitEmojiAsync(emoji);
}
protected override MarkdownNode VisitMention(MentionNode mention)
protected override ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention)
{
if (mention.Kind == MentionKind.Everyone)
{
@ -69,10 +70,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"@{name}");
}
return base.VisitMention(mention);
return base.VisitMentionAsync(mention);
}
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
protected override ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp)
{
_buffer.Append(
timestamp.Date is not null
@ -80,18 +81,18 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: "Invalid date"
);
return base.VisitUnixTimestamp(timestamp);
return base.VisitUnixTimestampAsync(timestamp);
}
}
internal partial class PlainTextMarkdownVisitor
{
public static string Format(ExportContext context, string markdown)
public static async ValueTask<string> FormatAsync(ExportContext context, string markdown)
{
var nodes = MarkdownParser.ParseMinimal(markdown);
var buffer = new StringBuilder();
new PlainTextMarkdownVisitor(context, buffer).Visit(nodes);
await new PlainTextMarkdownVisitor(context, buffer).VisitAsync(nodes);
return buffer.ToString();
}

@ -19,8 +19,8 @@ internal class PlainTextMessageWriter : MessageWriter
_writer = new StreamWriter(stream);
}
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
private async ValueTask WriteMessageHeaderAsync(Message message)
{
@ -71,18 +71,18 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync(embed.Url);
if (!string.IsNullOrWhiteSpace(embed.Title))
await _writer.WriteLineAsync(FormatMarkdown(embed.Title));
await _writer.WriteLineAsync(await FormatMarkdownAsync(embed.Title));
if (!string.IsNullOrWhiteSpace(embed.Description))
await _writer.WriteLineAsync(FormatMarkdown(embed.Description));
await _writer.WriteLineAsync(await FormatMarkdownAsync(embed.Description));
foreach (var field in embed.Fields)
{
if (!string.IsNullOrWhiteSpace(field.Name))
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
await _writer.WriteLineAsync(await FormatMarkdownAsync(field.Name));
if (!string.IsNullOrWhiteSpace(field.Value))
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
await _writer.WriteLineAsync(await FormatMarkdownAsync(field.Value));
}
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
@ -174,7 +174,7 @@ internal class PlainTextMessageWriter : MessageWriter
// Content
if (!string.IsNullOrWhiteSpace(message.Content))
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
await _writer.WriteLineAsync(await FormatMarkdownAsync(message.Content));
await _writer.WriteLineAsync();

@ -1,56 +1,57 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal abstract class MarkdownVisitor
{
protected virtual MarkdownNode VisitText(TextNode text) =>
text;
protected virtual ValueTask<MarkdownNode> VisitTextAsync(TextNode text) =>
new(text);
protected virtual MarkdownNode VisitFormatting(FormattingNode formatting)
protected virtual async ValueTask<MarkdownNode> VisitFormattingAsync(FormattingNode formatting)
{
Visit(formatting.Children);
await VisitAsync(formatting.Children);
return formatting;
}
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) =>
inlineCodeBlock;
protected virtual ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock) =>
new(inlineCodeBlock);
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) =>
multiLineCodeBlock;
protected virtual ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(MultiLineCodeBlockNode multiLineCodeBlock) =>
new(multiLineCodeBlock);
protected virtual MarkdownNode VisitLink(LinkNode link)
protected virtual async ValueTask<MarkdownNode> VisitLinkAsync(LinkNode link)
{
Visit(link.Children);
await VisitAsync(link.Children);
return link;
}
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) =>
emoji;
protected virtual ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji) =>
new(emoji);
protected virtual MarkdownNode VisitMention(MentionNode mention) =>
mention;
protected virtual ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention) =>
new(mention);
protected virtual MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) =>
timestamp;
protected virtual ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp) =>
new(timestamp);
public MarkdownNode Visit(MarkdownNode node) => node switch
public async ValueTask<MarkdownNode> VisitAsync(MarkdownNode node) => node switch
{
TextNode text => VisitText(text),
FormattingNode formatting => VisitFormatting(formatting),
InlineCodeBlockNode inlineCodeBlock => VisitInlineCodeBlock(inlineCodeBlock),
MultiLineCodeBlockNode multiLineCodeBlock => VisitMultiLineCodeBlock(multiLineCodeBlock),
LinkNode link => VisitLink(link),
EmojiNode emoji => VisitEmoji(emoji),
MentionNode mention => VisitMention(mention),
UnixTimestampNode timestamp => VisitUnixTimestamp(timestamp),
TextNode text => await VisitTextAsync(text),
FormattingNode formatting => await VisitFormattingAsync(formatting),
InlineCodeBlockNode inlineCodeBlock => await VisitInlineCodeBlockAsync(inlineCodeBlock),
MultiLineCodeBlockNode multiLineCodeBlock => await VisitMultiLineCodeBlockAsync(multiLineCodeBlock),
LinkNode link => await VisitLinkAsync(link),
EmojiNode emoji => await VisitEmojiAsync(emoji),
MentionNode mention => await VisitMentionAsync(mention),
UnixTimestampNode timestamp => await VisitUnixTimestampAsync(timestamp),
_ => throw new ArgumentOutOfRangeException(nameof(node))
};
public void Visit(IEnumerable<MarkdownNode> nodes)
public async ValueTask VisitAsync(IEnumerable<MarkdownNode> nodes)
{
foreach (var node in nodes)
Visit(node);
await VisitAsync(node);
}
}
Loading…
Cancel
Save