[HTML] Recognize standard emoji by code and show emoji code in tooltips

Closes #549
Closes #599
pull/613/head
Tyrrrz 3 years ago
parent de57cd714d
commit 24a80f915f

@ -1,7 +1,7 @@
using System.Collections.Generic; using System.Linq;
using System.Linq;
using System.Text;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
@ -10,10 +10,17 @@ namespace DiscordChatExporter.Core.Discord.Data
// https://discord.com/developers/docs/resources/emoji#emoji-object // https://discord.com/developers/docs/resources/emoji#emoji-object
public partial class Emoji public partial class Emoji
{ {
// Only present on custom emoji
public string? Id { get; } public string? Id { get; }
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
public string Name { get; } public string Name { get; }
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
public string Code => !string.IsNullOrWhiteSpace(Id)
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
public bool IsAnimated { get; } public bool IsAnimated { get; }
public string ImageUrl { get; } public string ImageUrl { get; }
@ -31,21 +38,12 @@ namespace DiscordChatExporter.Core.Discord.Data
public partial class Emoji public partial class Emoji
{ {
private static IEnumerable<Rune> GetRunes(string emoji) private static string GetTwemojiName(string name) => name
{ .GetRunes()
var lastIndex = 0; // Variant selector rune is skipped in Twemoji names
while (lastIndex < emoji.Length && Rune.TryGetRuneAt(emoji, lastIndex, out var rune)) .Where(r => r.Value != 0xfe0f)
{ .Select(r => r.Value.ToString("x"))
// Skip variant selector rune .JoinToString("-");
if (rune.Value != 0xfe0f)
yield return rune;
lastIndex += rune.Utf16SequenceLength;
}
}
private static string GetTwemojiName(IEnumerable<Rune> runes) =>
runes.Select(r => r.Value.ToString("x")).JoinToString("-");
public static string GetImageUrl(string? id, string name, bool isAnimated) public static string GetImageUrl(string? id, string name, bool isAnimated)
{ {
@ -58,8 +56,7 @@ namespace DiscordChatExporter.Core.Discord.Data
} }
// Standard emoji // Standard emoji
var emojiRunes = GetRunes(name).ToArray(); var twemojiName = GetTwemojiName(name);
var twemojiName = GetTwemojiName(emojiRunes);
return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png"; return $"https://twemoji.maxcdn.com/2/72x72/{twemojiName}.png";
} }

@ -311,7 +311,7 @@
<div class="chatlog__reactions"> <div class="chatlog__reactions">
@foreach (var reaction in message.Reactions) @foreach (var reaction in message.Reactions)
{ {
<div class="chatlog__reaction" title="@reaction.Emoji.Name"> <div class="chatlog__reaction" title="@reaction.Emoji.Code">
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)"> <img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
<span class="chatlog__reaction-count">@reaction.Count</span> <span class="chatlog__reaction-count">@reaction.Count</span>
</div> </div>

@ -131,7 +131,7 @@ namespace DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
var jumboClass = _isJumbo ? "emoji--large" : ""; var jumboClass = _isJumbo ? "emoji--large" : "";
_buffer _buffer
.Append($"<img class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Name}\" src=\"{emojiImageUrl}\">"); .Append($"<img class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
return base.VisitEmoji(emoji); return base.VisitEmoji(emoji);
} }

@ -1,11 +1,20 @@
namespace DiscordChatExporter.Core.Markdown.Ast using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown.Ast
{ {
internal class EmojiNode : MarkdownNode internal class EmojiNode : MarkdownNode
{ {
// Only present on custom emoji
public string? Id { get; } public string? Id { get; }
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
public string Name { get; } public string Name { get; }
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
public string Code => !string.IsNullOrWhiteSpace(Id)
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
public bool IsAnimated { get; } public bool IsAnimated { get; }
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id); public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);

@ -2,16 +2,6 @@
namespace DiscordChatExporter.Core.Markdown.Ast namespace DiscordChatExporter.Core.Markdown.Ast
{ {
internal enum TextFormatting
{
Bold,
Italic,
Underline,
Strikethrough,
Spoiler,
Quote
}
internal class FormattedNode : MarkdownNode internal class FormattedNode : MarkdownNode
{ {
public TextFormatting Formatting { get; } public TextFormatting Formatting { get; }

@ -1,13 +1,5 @@
namespace DiscordChatExporter.Core.Markdown.Ast namespace DiscordChatExporter.Core.Markdown.Ast
{ {
internal enum MentionType
{
Meta,
User,
Channel,
Role
}
internal class MentionNode : MarkdownNode internal class MentionNode : MarkdownNode
{ {
public string Id { get; } public string Id { get; }

@ -0,0 +1,10 @@
namespace DiscordChatExporter.Core.Markdown.Ast
{
internal enum MentionType
{
Meta,
User,
Channel,
Role
}
}

@ -0,0 +1,12 @@
namespace DiscordChatExporter.Core.Markdown.Ast
{
internal enum TextFormatting
{
Bold,
Italic,
Underline,
Strikethrough,
Spoiler,
Quote
}
}

@ -3,6 +3,7 @@ using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Markdown.Ast; using DiscordChatExporter.Core.Markdown.Ast;
using DiscordChatExporter.Core.Markdown.Matching; using DiscordChatExporter.Core.Markdown.Matching;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Markdown namespace DiscordChatExporter.Core.Markdown
{ {
@ -103,7 +104,7 @@ namespace DiscordChatExporter.Core.Markdown
// There can be either one or two backticks, but equal number on both sides // There can be either one or two backticks, but equal number on both sides
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("(`{1,2})([^`]+)\\1", DefaultRegexOptions | RegexOptions.Singleline), new Regex("(`{1,2})([^`]+)\\1", DefaultRegexOptions | RegexOptions.Singleline),
m => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n')) (_, m) => new InlineCodeBlockNode(m.Groups[2].Value.Trim('\r', '\n'))
); );
// Capture language identifier and then any character until the earliest triple backtick // Capture language identifier and then any character until the earliest triple backtick
@ -111,7 +112,7 @@ namespace DiscordChatExporter.Core.Markdown
// Blank lines at the beginning and end of content are trimmed // Blank lines at the beginning and end of content are trimmed
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), new Regex("```(?:(\\w*)\\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
m => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')) (_, m) => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
); );
/* Mentions */ /* Mentions */
@ -131,19 +132,19 @@ namespace DiscordChatExporter.Core.Markdown
// Capture <@123456> or <@!123456> // Capture <@123456> or <@!123456>
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@!?(\\d+)>", DefaultRegexOptions), new Regex("<@!?(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Groups[1].Value, MentionType.User) (_, m) => new MentionNode(m.Groups[1].Value, MentionType.User)
); );
// Capture <#123456> // Capture <#123456>
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<#(\\d+)>", DefaultRegexOptions), new Regex("<#(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Groups[1].Value, MentionType.Channel) (_, m) => new MentionNode(m.Groups[1].Value, MentionType.Channel)
); );
// Capture <@&123456> // Capture <@&123456>
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<@&(\\d+)>", DefaultRegexOptions), new Regex("<@&(\\d+)>", DefaultRegexOptions),
m => new MentionNode(m.Groups[1].Value, MentionType.Role) (_, m) => new MentionNode(m.Groups[1].Value, MentionType.Role)
); );
/* Emojis */ /* Emojis */
@ -154,15 +155,26 @@ namespace DiscordChatExporter.Core.Markdown
// ... or digit followed by enclosing mark // ... or digit followed by enclosing mark
// (this does not match all emojis in Discord but it's reasonably accurate enough) // (this does not match all emojis in Discord but it's reasonably accurate enough)
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})", new Regex("((?:[\\uD83C][\\uDDE6-\\uDDFF]){2}|[\\u2600-\\u26FF]|\\p{Cs}{2}|\\d\\p{Me})", DefaultRegexOptions),
DefaultRegexOptions), (_, m) => new EmojiNode(m.Groups[1].Value)
m => new EmojiNode(m.Groups[1].Value) );
// Capture :thinking: (but only for known emoji codes)
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex(":([\\w_]+):", DefaultRegexOptions),
(_, m) =>
{
var name = EmojiIndex.TryGetName(m.Groups[1].Value);
return name is not null
? new EmojiNode(name)
: null;
}
); );
// Capture <:lul:123456> or <a:lul:123456> // Capture <:lul:123456> or <a:lul:123456>
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions), new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
m => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value)) (_, m) => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))
); );
/* Links */ /* Links */
@ -170,19 +182,19 @@ namespace DiscordChatExporter.Core.Markdown
// Capture [title](link) // Capture [title](link)
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions), new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
m => new LinkNode(m.Groups[2].Value, m.Groups[1].Value) (_, m) => new LinkNode(m.Groups[2].Value, m.Groups[1].Value)
); );
// Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace // Capture any non-whitespace character after http:// or https:// until the last punctuation character or whitespace
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions), new Regex("(https?://\\S*[^\\.,:;\"\'\\s])", DefaultRegexOptions),
m => new LinkNode(m.Groups[1].Value) (_, m) => new LinkNode(m.Groups[1].Value)
); );
// Same as auto link but also surrounded by angular brackets // Same as auto link but also surrounded by angular brackets
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions), new Regex("<(https?://\\S*[^\\.,:;\"\'\\s])>", DefaultRegexOptions),
m => new LinkNode(m.Groups[1].Value) (_, m) => new LinkNode(m.Groups[1].Value)
); );
/* Text */ /* Text */
@ -198,21 +210,21 @@ namespace DiscordChatExporter.Core.Markdown
// This escapes it from matching for emoji // This escapes it from matching for emoji
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions), new Regex("(\\u26A7|\\u2640|\\u2642|\\u2695|\\u267E|\\u00A9|\\u00AE|\\u2122)", DefaultRegexOptions),
m => new TextNode(m.Groups[1].Value) (_, m) => new TextNode(m.Groups[1].Value)
); );
// Capture any "symbol/other" character or surrogate pair preceded by a backslash // Capture any "symbol/other" character or surrogate pair preceded by a backslash
// This escapes it from matching for emoji // This escapes it from matching for emoji
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions), new Regex("\\\\(\\p{So}|\\p{Cs}{2})", DefaultRegexOptions),
m => new TextNode(m.Groups[1].Value) (_, m) => new TextNode(m.Groups[1].Value)
); );
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash
// This escapes it from matching for formatting or other tokens // This escapes it from matching for formatting or other tokens
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>(
new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions), new Regex("\\\\([^a-zA-Z0-9\\s])", DefaultRegexOptions),
m => new TextNode(m.Groups[1].Value) (_, m) => new TextNode(m.Groups[1].Value)
); );
// Combine all matchers into one // Combine all matchers into one
@ -255,7 +267,8 @@ namespace DiscordChatExporter.Core.Markdown
// Emoji // Emoji
StandardEmojiNodeMatcher, StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher CustomEmojiNodeMatcher,
CodedStandardEmojiNodeMatcher
); );
// Minimal set of matchers for non-multimedia formats (e.g. plain text) // Minimal set of matchers for non-multimedia formats (e.g. plain text)

@ -6,19 +6,14 @@ namespace DiscordChatExporter.Core.Markdown.Matching
internal class RegexMatcher<T> : IMatcher<T> internal class RegexMatcher<T> : IMatcher<T>
{ {
private readonly Regex _regex; private readonly Regex _regex;
private readonly Func<StringPart, Match, T> _transform; private readonly Func<StringPart, Match, T?> _transform;
public RegexMatcher(Regex regex, Func<StringPart, Match, T> transform) public RegexMatcher(Regex regex, Func<StringPart, Match, T?> transform)
{ {
_regex = regex; _regex = regex;
_transform = transform; _transform = transform;
} }
public RegexMatcher(Regex regex, Func<Match, T> transform)
: this(regex, (p, m) => transform(m))
{
}
public ParsedMatch<T>? TryMatch(StringPart stringPart) public ParsedMatch<T>? TryMatch(StringPart stringPart)
{ {
var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length); var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length);
@ -30,11 +25,15 @@ namespace DiscordChatExporter.Core.Markdown.Matching
// Which is super weird because regex.Match(string, int) takes the whole input in context. // Which is super weird because regex.Match(string, int) takes the whole input in context.
// So in order to properly account for ^/$ regex tokens, we need to make sure that // So in order to properly account for ^/$ regex tokens, we need to make sure that
// the expression also matches on the bigger part of the input. // the expression also matches on the bigger part of the input.
if (!_regex.IsMatch(stringPart.Target.Substring(0, stringPart.EndIndex), stringPart.StartIndex)) if (!_regex.IsMatch(stringPart.Target[..stringPart.EndIndex], stringPart.StartIndex))
return null; return null;
var stringPartMatch = stringPart.Slice(match.Index, match.Length); var stringPartMatch = stringPart.Slice(match.Index, match.Length);
return new ParsedMatch<T>(stringPartMatch, _transform(stringPartMatch, match)); var value = _transform(stringPartMatch, match);
return value is not null
? new ParsedMatch<T>(stringPartMatch, value)
: null;
} }
} }
} }

@ -6,9 +6,9 @@ namespace DiscordChatExporter.Core.Markdown.Matching
{ {
private readonly string _needle; private readonly string _needle;
private readonly StringComparison _comparison; private readonly StringComparison _comparison;
private readonly Func<StringPart, T> _transform; private readonly Func<StringPart, T?> _transform;
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T> transform) public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T?> transform)
{ {
_needle = needle; _needle = needle;
_comparison = comparison; _comparison = comparison;
@ -23,14 +23,15 @@ namespace DiscordChatExporter.Core.Markdown.Matching
public ParsedMatch<T>? TryMatch(StringPart stringPart) public ParsedMatch<T>? TryMatch(StringPart stringPart)
{ {
var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison); var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
if (index < 0)
return null;
if (index >= 0) var stringPartMatch = stringPart.Slice(index, _needle.Length);
{ var value = _transform(stringPartMatch);
var stringPartMatch = stringPart.Slice(index, _needle.Length);
return new ParsedMatch<T>(stringPartMatch, _transform(stringPartMatch));
}
return null; return value is not null
? new ParsedMatch<T>(stringPartMatch, value)
: null;
} }
} }
} }

@ -10,14 +10,13 @@ namespace DiscordChatExporter.Core.Markdown.Matching
public int Length { get; } public int Length { get; }
public int EndIndex { get; } public int EndIndex => StartIndex + Length;
public StringPart(string target, int startIndex, int length) public StringPart(string target, int startIndex, int length)
{ {
Target = target; Target = target;
StartIndex = startIndex; StartIndex = startIndex;
Length = length; Length = length;
EndIndex = startIndex + length;
} }
public StringPart(string target) public StringPart(string target)

File diff suppressed because it is too large Load Diff

@ -1,4 +1,5 @@
using System.Text; using System.Collections.Generic;
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions namespace DiscordChatExporter.Core.Utils.Extensions
{ {
@ -14,6 +15,16 @@ namespace DiscordChatExporter.Core.Utils.Extensions
? str[..charCount] ? str[..charCount]
: str; : str;
public static IEnumerable<Rune> GetRunes(this string str)
{
var lastIndex = 0;
while (lastIndex < str.Length && Rune.TryGetRuneAt(str, lastIndex, out var rune))
{
yield return rune;
lastIndex += rune.Utf16SequenceLength;
}
}
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 builder.Length > 0
? builder.Append(value) ? builder.Append(value)

Loading…
Cancel
Save