diff --git a/chat_exporter/construct/assets/embed.py b/chat_exporter/construct/assets/embed.py index edab542..23a0712 100644 --- a/chat_exporter/construct/assets/embed.py +++ b/chat_exporter/construct/assets/embed.py @@ -21,7 +21,7 @@ def _gather_checker(): - if hasattr(discord.Embed, "Empty") and discord.module != "nextcord": + if hasattr(discord.Embed, "Empty") and (discord.module != "nextcord" or discord.__version__ < 2.2): return discord.Embed.Empty return None diff --git a/chat_exporter/construct/message.py b/chat_exporter/construct/message.py index 9daf252..eea49ac 100644 --- a/chat_exporter/construct/message.py +++ b/chat_exporter/construct/message.py @@ -59,9 +59,9 @@ def __init__( self.military_time = military_time self.guild = guild - self.time_format = "%A, %d %B %Y at %I:%M %p" + self.time_format = "%A, %e %B %Y %I:%M %p" if self.military_time: - self.time_format = "%A, %d %B %Y at %H:%M" + self.time_format = "%A, %e %B %Y %H:%M" self.message_created_at, self.message_edited_at = self.set_time() self.meta_data = meta_data @@ -106,7 +106,15 @@ async def build_meta_data(self): self.message.author.display_avatar if self.message.author.display_avatar else DiscordUtils.default_avatar ) - self.meta_data[user_id] = [user_name_discriminator, user_created_at, user_bot, user_avatar, 1] + user_joined_at = self.message.author.joined_at + user_display_name = ( + f'
{self.message.author.display_name}
' + if self.message.author.display_name != self.message.author.name + else "" + ) + self.meta_data[user_id] = [ + user_name_discriminator, user_created_at, user_bot, user_avatar, 1, user_joined_at, user_display_name + ] async def build_content(self): if not self.message.content: diff --git a/chat_exporter/construct/transcript.py b/chat_exporter/construct/transcript.py index 15e8eec..af8419c 100644 --- a/chat_exporter/construct/transcript.py +++ b/chat_exporter/construct/transcript.py @@ -14,7 +14,9 @@ from chat_exporter.ext.cache import clear_cache from chat_exporter.parse.mention import pass_bot from chat_exporter.ext.discord_utils import DiscordUtils -from chat_exporter.ext.html_generator import fill_out, total, meta_data_temp, fancy_time, PARSE_MODE_NONE +from chat_exporter.ext.html_generator import ( + fill_out, total, channel_topic, meta_data_temp, fancy_time, channel_subject, PARSE_MODE_NONE +) class TranscriptDAO: @@ -39,6 +41,9 @@ def __init__( self.support_dev = support_dev self.pytz_timezone = pytz_timezone + # This is to pass timezone in to mention.py without rewriting + setattr(discord.Guild, "timezone", self.pytz_timezone) + if bot: pass_bot(bot) @@ -66,7 +71,8 @@ async def export_transcript(self, message_html: str, meta_data: str): meta_data_html: str = "" for data in meta_data: - creation_time = meta_data[int(data)][1].astimezone(timezone).strftime("%d/%m/%y @ %T") + creation_time = meta_data[int(data)][1].astimezone(timezone).strftime("%b %d, %Y") + joined_time = meta_data[int(data)][5].astimezone(timezone).strftime("%b %d, %Y") meta_data_html += await fill_out(self.channel.guild, meta_data_temp, [ ("USER_ID", str(data), PARSE_MODE_NONE), @@ -74,13 +80,34 @@ async def export_transcript(self, message_html: str, meta_data: str): ("DISCRIMINATOR", str(meta_data[int(data)][0][-5:])), ("BOT", str(meta_data[int(data)][2]), PARSE_MODE_NONE), ("CREATED_AT", str(creation_time), PARSE_MODE_NONE), + ("JOINED_AT", str(joined_time), PARSE_MODE_NONE), + ("GUILD_ICON", str(guild_icon), PARSE_MODE_NONE), + ("DISCORD_ICON", str(DiscordUtils.logo), PARSE_MODE_NONE), + ("MEMBER_ID", str(data), PARSE_MODE_NONE), ("USER_AVATAR", str(meta_data[int(data)][3]), PARSE_MODE_NONE), + ("DISPLAY", str(meta_data[int(data)][6]), PARSE_MODE_NONE), ("MESSAGE_COUNT", str(meta_data[int(data)][4])) ]) - channel_creation_time = self.channel.created_at.astimezone(timezone).strftime("%d/%m/%y @ %T") + channel_creation_time = self.channel.created_at.astimezone(timezone).strftime("%b %d, %Y (%T)") + + raw_channel_topic = self.channel.topic if self.channel.topic else "" + + channel_topic_html = "" + if raw_channel_topic: + channel_topic_html = await fill_out(self.channel.guild, channel_topic, [ + ("CHANNEL_TOPIC", raw_channel_topic) + ]) + + limit = "start" + if self.limit: + limit = f"latest {self.limit} messages" - channel_topic = f'{self.channel.topic}' if self.channel.topic else "" + subject = await fill_out(self.channel.guild, channel_subject, [ + ("LIMIT", limit, PARSE_MODE_NONE), + ("CHANNEL_NAME", self.channel.name), + ("RAW_CHANNEL_TOPIC", str(raw_channel_topic)) + ]) sd = ( '
' @@ -102,10 +129,10 @@ async def export_transcript(self, message_html: str, meta_data: str): ("MESSAGE_COUNT", str(len(self.messages))), ("MESSAGES", message_html, PARSE_MODE_NONE), ("META_DATA", meta_data_html, PARSE_MODE_NONE), - ("TIMEZONE", str(self.pytz_timezone)), ("DATE_TIME", str(time_now)), + ("SUBJECT", subject, PARSE_MODE_NONE), ("CHANNEL_CREATED_AT", str(channel_creation_time), PARSE_MODE_NONE), - ("CHANNEL_TOPIC", str(channel_topic), PARSE_MODE_NONE), + ("CHANNEL_TOPIC", str(channel_topic_html), PARSE_MODE_NONE), ("CHANNEL_ID", str(self.channel.id), PARSE_MODE_NONE), ("MESSAGE_PARTICIPANTS", str(len(meta_data)), PARSE_MODE_NONE), ("FANCY_TIME", _fancy_time, PARSE_MODE_NONE), diff --git a/chat_exporter/ext/discord_utils.py b/chat_exporter/ext/discord_utils.py index a6766e3..ee9f433 100644 --- a/chat_exporter/ext/discord_utils.py +++ b/chat_exporter/ext/discord_utils.py @@ -1,4 +1,5 @@ class DiscordUtils: + logo: str = 'https://cdn.jsdelivr.net/gh/mahtoid/DiscordUtils@master/discord-logo.svg' default_avatar: str = 'https://cdn.jsdelivr.net/gh/mahtoid/DiscordUtils@master/discord-default.png' pinned_message_icon: str = 'https://cdn.jsdelivr.net/gh/mahtoid/DiscordUtils@master/discord-pinned.svg' thread_channel_icon: str = 'https://cdn.jsdelivr.net/gh/mahtoid/DiscordUtils@master/discord-thread.svg' diff --git a/chat_exporter/ext/html_generator.py b/chat_exporter/ext/html_generator.py index 3fc7bf6..d30e148 100644 --- a/chat_exporter/ext/html_generator.py +++ b/chat_exporter/ext/html_generator.py @@ -23,7 +23,7 @@ async def fill_out(guild, base, replacements): k, v, mode = r if mode != PARSE_MODE_NONE: - v = ParseMention(v, guild).flow() + v = await ParseMention(v, guild).flow() if mode == PARSE_MODE_MARKDOWN: v = await ParseMarkdown(v).standard_message_flow() elif mode == PARSE_MODE_EMBED: @@ -92,3 +92,5 @@ def read_file(filename): # SCRIPT fancy_time = read_file(dir_path + "/html/script/fancy_time.html") +channel_topic = read_file(dir_path + "/html/script/channel_topic.html") +channel_subject = read_file(dir_path + "/html/script/channel_subject.html") diff --git a/chat_exporter/html/base.html b/chat_exporter/html/base.html index 55919c9..6c4be8f 100644 --- a/chat_exporter/html/base.html +++ b/chat_exporter/html/base.html @@ -131,6 +131,7 @@ .spoiler--hidden { cursor: pointer; + background-color: #202225; } .spoiler-text { @@ -138,7 +139,7 @@ } .spoiler--hidden .spoiler-text { - color: rgba(0, 0, 0, 0); + opacity: 0; } .spoiler--hidden .spoiler-text::selection { @@ -203,6 +204,12 @@ font-size: 0.85em; } + .unix-timestamp { + background: #40444b; + border-radius: 3px; + padding: 0 2px; + } + .mention { border-radius: 3px; padding: 0 2px; @@ -399,10 +406,6 @@ unicode-bidi: bidi-override; } - .chatlog__message .chatlog__bot-tag { - top: -0.2em; - } - .chatlog__avatar { width: 40px; height: 40px; @@ -625,17 +628,29 @@ } .chatlog__bot-tag { - position: relative; - margin-left: 0.3em; - margin-right: 0.3em; - padding: 0.05em 0.3em; - border-radius: 3px; - vertical-align: middle; - line-height: 1.3; - background: #7289da; + background: #5865f2; color: #ffffff; - font-size: 0.625em; + padding: 0 0.2rem; + margin-top: 0.5em; + border-radius: 0.1875rem; + margin-left: 0.07rem; + position: relative; + vertical-align: top; + display: inline-flex; + flex-shrink: 0; + text-indent: 0; font-weight: 500; + font-size: 10px; + line-height: 15px; + } + + .chatlog__reference .chatlog__bot-tag { + margin-right: 0.3rem; + margin-top: 0; + } + + .meta__details .chatlog__bot-tag { + margin-left: 1ch; } /* Postamble */ @@ -875,16 +890,16 @@ } .info__title { - font-size: 32px; - font-weight: 700; + font-size: 30px; + font-weight: 500; line-height: 40px; - margin: 8px 0; } .info__subject { color: #b9bbbe; font-size: 16px; - line-height:20px; + line-height: 20px; + font-family: "Whitney"; } .info__channel-message-count { @@ -951,29 +966,51 @@ .meta-popout { position: absolute; z-index: 6969; - background-color: #36393f; + background-color: #292b2f; box-shadow: 0 2px 10px 0 rgb(0 0 0 / 20%), 0 0 0 1px rgb(32 34 37 / 60%); - width: 250px; + width: 280px; border-radius: 5px; overflow: hidden; transform: scale(0); transform-origin: top left; } + .meta-popout img { + height: 16px; + width: 16px; + } + + .meta__img-border { + border-radius: 50%; + } + .meta-popout.mounted { transform: scale(1); transition: transform .3 ease-in-out;; } + .meta__divider { + height: 4px; + width: 4px; + border-radius: 50%; + background-color: #4f545c; + } + + .meta__divider-2 { + margin: 5px 12px 10px; + height: 1px; + background-color: #4f545c; + } + .meta__header { display: flex; flex-direction: column; align-items: center; justify-content: center; background-color: #202225; - padding: 20px 10px; + padding-top: 10px; } .meta__header img { @@ -987,22 +1024,33 @@ .meta__details { display: flex; - align-items: center; - justify-content: center; flex-wrap: wrap; + padding-bottom: 7px; + } + + .meta__display-name { + font-weight: 500; + color: #fff; + opacity: 0.9; + text-overflow: ellipsis; + overflow: hidden; + font-family: 'Ginto'; + font-size: 12px; } .meta__user { - font-weight: 600; + font-weight: 500; color: #fff; text-overflow: ellipsis; overflow: hidden; + font-family: 'Ginto'; } .meta__discriminator { font-weight: 500; color: #fff; opacity: .6; + font-family: 'Ginto'; } .meta-popout .meta__header .bot { @@ -1011,6 +1059,9 @@ .meta__description { padding: 10px; + background-color: #18191c; + margin: 10px; + border-radius: 5%; } .meta__field { @@ -1022,12 +1073,16 @@ color: #72767d; text-transform: uppercase; font-size: 12px; + line-height: 16px; margin-bottom: 1px; } .meta__value { - font-size: 13px; - line-height: 14px; + font-size: 14px; + line-height: 16px; + align-items: center; + display: flex; + column-gap: 6px; } .meta__support { @@ -1242,8 +1297,8 @@
- Welcome to #{{CHANNEL_NAME}} - This is the start of the #{{CHANNEL_NAME}} channel. + Welcome to #{{CHANNEL_NAME}}! + {{SUBJECT}}
@@ -1262,11 +1317,11 @@
Avatar +
+
{{SERVER_NAME}}
-
-
Channel ID
{{CHANNEL_ID}}
@@ -1283,10 +1338,6 @@
Total Message Participants
{{MESSAGE_PARTICIPANTS}}
-
-
Transcript Timezone
-
{{TIMEZONE}}
-
{{SD}}
@@ -1444,6 +1495,14 @@ theme: 'disc', }); + + tippy('.unix-timestamp', { + placement: 'top', + animation: 'fade', + content: (reference) => reference.getAttribute('data-timestamp'), + theme: 'disc', + }); + {{FANCY_TIME}} diff --git a/chat_exporter/html/message/meta.html b/chat_exporter/html/message/meta.html index 46641cf..f53135a 100644 --- a/chat_exporter/html/message/meta.html +++ b/chat_exporter/html/message/meta.html @@ -2,16 +2,22 @@
Avatar +
+
+ {{DISPLAY}}
{{USERNAME}}
{{DISCRIMINATOR}}
{{BOT}}
-
-
+
+
+
Member Since
+
{{CREATED_AT}}
{{JOINED_AT}}
+
-
Account Creation Date
-
{{CREATED_AT}}
+
Member ID
+
{{MEMBER_ID}}
Message Count
diff --git a/chat_exporter/html/script/channel_subject.html b/chat_exporter/html/script/channel_subject.html new file mode 100644 index 0000000..dd31c90 --- /dev/null +++ b/chat_exporter/html/script/channel_subject.html @@ -0,0 +1 @@ +This is the {{LIMIT}} of the #{{CHANNEL_NAME}} channel. {{RAW_CHANNEL_TOPIC}} \ No newline at end of file diff --git a/chat_exporter/html/script/channel_topic.html b/chat_exporter/html/script/channel_topic.html new file mode 100644 index 0000000..670915e --- /dev/null +++ b/chat_exporter/html/script/channel_topic.html @@ -0,0 +1 @@ +{{CHANNEL_TOPIC}} \ No newline at end of file diff --git a/chat_exporter/parse/markdown.py b/chat_exporter/parse/markdown.py index c1abaf2..f9a3c7a 100644 --- a/chat_exporter/parse/markdown.py +++ b/chat_exporter/parse/markdown.py @@ -1,3 +1,4 @@ +import html import re from chat_exporter.ext.emoji_convert import convert_emoji @@ -225,13 +226,16 @@ def parse_embed_markdown(self): @staticmethod def return_to_markdown(content): - holders = [r"(.*?)", '**%s**'], \ - [r"([^<>]+)", '*%s*'], \ - [r'([^<>]+)', '__%s__'], \ - [r'([^<>]+)', '~~%s~~'], \ - [r'
(.*?)
', '> %s'], \ - [r' (.*?)<\/span><\/span>', '||%s||'] + holders = ( + [r"(.*?)", '**%s**'], + [r"([^<>]+)", '*%s*'], + [r'([^<>]+)', '__%s__'], + [r'([^<>]+)', '~~%s~~'], + [r'
(.*?)
', '> %s'], + [r' (.*?)<\/span><\/span>', '||%s||'], + [r'.*?', '%s'] + ) for x in holders: p, r = x @@ -241,7 +245,7 @@ def return_to_markdown(content): while match is not None: affected_text = match.group(1) content = content.replace(content[match.start():match.end()], - r % affected_text) + r % html.escape(affected_text)) match = re.search(pattern, content) pattern = re.compile(r'(.*?)') @@ -260,22 +264,32 @@ def return_to_markdown(content): return content.lstrip().rstrip() def https_http_links(self): - def remove_silent_link(url): - if url.startswith("<<") and url.endswith(">>"): - return url[1:-1] + def remove_silent_link(url, raw_url=None): + pattern = rf"`.*{raw_url}.*`" + match = re.search(pattern, self.content) + + if "<" in url and ">" in url and not match: + return url.replace("<", "").replace(">", "") return url content = re.sub("\n", "
", self.content) output = [] if "http://" in content or "https://" in content and "](" not in content: for word in content.replace("
", "
").split(): - if word.startswith("<") and word.endswith(">"): + + if "http" not in word: + output.append(word) + continue + + if "<" in word and ">" in word: pattern = r"<https?:\/\/(.*)>" - url = re.search(pattern, word).group(1) - url = f'https://{url}' - output.append(url) + match_url = re.search(pattern, word).group(1) + url = f'https://{match_url}' + word = word.replace("https://" + match_url, url) + word = word.replace("http://" + match_url, url) + output.append(remove_silent_link(word, match_url)) elif "https://" in word: - pattern = r"https://[^\s>\"*]*" + pattern = r"https://[^\s>`\"*]*" word_link = re.search(pattern, word).group() if word_link.endswith(")"): output.append(word) @@ -284,7 +298,7 @@ def remove_silent_link(url): word = re.sub(pattern, word_full, word) output.append(remove_silent_link(word)) elif "http://" in word: - pattern = r"http://[^\s>\"*]*" + pattern = r"http://[^\s>`\"*]*" word_link = re.search(pattern, word).group() if word_link.endswith(")"): output.append(word) diff --git a/chat_exporter/parse/mention.py b/chat_exporter/parse/mention.py index 9316454..3c0a58b 100644 --- a/chat_exporter/parse/mention.py +++ b/chat_exporter/parse/mention.py @@ -1,6 +1,9 @@ import re from typing import Optional +import pytz +import datetime + from chat_exporter.ext.discord_import import discord bot: Optional[discord.Client] = None @@ -22,6 +25,16 @@ class ParseMention: REGEX_CHANNELS_2 = r"<#([0-9]+)>" REGEX_EMOJIS = r"<a?(:[^\n:]+:)[0-9]+>" REGEX_EMOJIS_2 = r"" + REGEX_TIME_HOLDER = ( + [r"<t:([0-9]+):t>", "%H:%M"], + [r"<t:([0-9]+):T>", "%T"], + [r"<t:([0-9]+):d>", "%d/%m/%Y"], + [r"<t:([0-9]+):D>", "%e %B %Y"], + [r"<t:([0-9]+):f>", "%e %B %Y %H:%M"], + [r"<t:([0-9]+):F>", "%A, %e %B %Y %H:%M"], + [r"<t:([0-9]+):R>", "%e %B %Y %H:%M"], + [r"<t:([0-9]+)>", "%e %B %Y %H:%M"] + ) ESCAPE_LT = "______lt______" ESCAPE_GT = "______gt______" @@ -31,17 +44,18 @@ def __init__(self, content, guild): self.content = content self.guild = guild - def flow(self): - self.escape_mentions() - self.escape_mentions() - self.unescape_mentions() - self.channel_mention() - self.member_mention() - self.role_mention() + async def flow(self): + await self.escape_mentions() + await self.escape_mentions() + await self.unescape_mentions() + await self.channel_mention() + await self.member_mention() + await self.role_mention() + await self.time_mention() return self.content - def escape_mentions(self): + async def escape_mentions(self): for match in re.finditer("(%s|%s|%s|%s|%s|%s|%s|%s)" % (self.REGEX_ROLES, self.REGEX_MEMBERS, self.REGEX_CHANNELS, self.REGEX_EMOJIS, self.REGEX_ROLES_2, self.REGEX_MEMBERS_2, self.REGEX_CHANNELS_2, @@ -56,13 +70,13 @@ def escape_mentions(self): self.content = pre_content + match_content + post_content - def unescape_mentions(self): + async def unescape_mentions(self): self.content = self.content.replace(self.ESCAPE_LT, "<") self.content = self.content.replace(self.ESCAPE_GT, ">") self.content = self.content.replace(self.ESCAPE_AMP, "&") pass - def channel_mention(self): + async def channel_mention(self): holder = self.REGEX_CHANNELS, self.REGEX_CHANNELS_2 for regex in holder: match = re.search(regex, self.content) @@ -79,7 +93,7 @@ def channel_mention(self): match = re.search(regex, self.content) - def role_mention(self): + async def role_mention(self): holder = self.REGEX_ROLES, self.REGEX_ROLES_2 for regex in holder: match = re.search(regex, self.content) @@ -100,7 +114,7 @@ def role_mention(self): match = re.search(regex, self.content) - def member_mention(self): + async def member_mention(self): holder = self.REGEX_MEMBERS, self.REGEX_MEMBERS_2 for regex in holder: match = re.search(regex, self.content) @@ -124,3 +138,25 @@ def member_mention(self): replacement) match = re.search(regex, self.content) + + async def time_mention(self): + holder = self.REGEX_TIME_HOLDER + timezone = pytz.timezone(self.guild.timezone) + + for p in holder: + regex, strf = p + match = re.search(regex, self.content) + while match is not None: + time = datetime.datetime.fromtimestamp(int(match.group(1)), timezone) + ui_time = time.strftime(strf) + tooltip_time = time.strftime("%A, %e %B %Y at %H:%M") + original = match.group().replace("<", "<").replace(">", ">") + replacement = ( + f'' + f'{ui_time}' + ) + + self.content = self.content.replace(self.content[match.start():match.end()], + replacement) + + match = re.search(regex, self.content)