mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-10 16:01:55 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da6d903f36 | |||
| e1b1755fba | |||
| 1ff026eba6 | |||
| 1fe4ecb3af | |||
| 47a1518cd9 | |||
| d78c10ebb7 | |||
| 3db29b520c | |||
| 752003abc3 | |||
| e26a0660d1 | |||
| 7dfc3b2723 | |||
| 06c33373de |
@@ -1,3 +1,12 @@
|
||||
### v2.21.2 (30-Jul-2020)
|
||||
|
||||
- [GUI] When copy-pasting token, any surrounding spaces are now discarded, in addition to double quotes.
|
||||
- [HTML] Changed underlying templating engine to provide higher performance and better error messages. New templating engine adds a small cold start, but any export after the first should be faster.
|
||||
- Changed naming schema for downloaded media so that it follows `<original filename> (nn)` format rather than `<original filename>-salt`.
|
||||
- [HTML] Fixed an issue where downloaded media was sometimes inaccessible due to reserved characters appearing in the URL.
|
||||
- Fixed an issue where the application crashed when trying to download media with a file name that exceeds system's maximum allowed length.
|
||||
- Fixed an issue where the application crashed when trying to download media with illegal characters in the file name.
|
||||
|
||||
### v2.21.1 (19-Jul-2020)
|
||||
|
||||
- Fixed an issue where the export crashed if an embedded image has been deleted. Such media files are now ignored.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="1.2.0" />
|
||||
<PackageReference Include="CliFx" Version="1.3.1" />
|
||||
<PackageReference Include="Gress" Version="1.2.0" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
<Import Project="../DiscordChatExporter.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MiniRazor" Version="1.0.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.1" />
|
||||
<PackageReference Include="Scriban" Version="2.1.2" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Core.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Dark.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\LayoutTemplate.html" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\Light.css" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\MessageGroupTemplate.html" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\PreambleTemplate.cshtml" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\PostambleTemplate.cshtml" />
|
||||
<EmbeddedResource Include="Exporting\Writers\Html\MessageGroupTemplate.cshtml" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
@@ -34,6 +37,8 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath);
|
||||
}
|
||||
|
||||
public string FormatDate(DateTimeOffset date) => date.ToLocalString(Request.DateFormat);
|
||||
|
||||
public Member? TryGetMember(string id) =>
|
||||
Members.FirstOrDefault(m => m.Id == id);
|
||||
|
||||
@@ -43,9 +48,9 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
public Role? TryGetRole(string id) =>
|
||||
Roles.FirstOrDefault(r => r.Id == id);
|
||||
|
||||
public Color? TryGetUserColor(User user)
|
||||
public Color? TryGetUserColor(string id)
|
||||
{
|
||||
var member = TryGetMember(user.Id);
|
||||
var member = TryGetMember(id);
|
||||
var roles = member?.RoleIds.Join(Roles, i => i, r => r.Id, (_, role) => role);
|
||||
|
||||
return roles?
|
||||
@@ -55,7 +60,6 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
// HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter
|
||||
public async ValueTask<string> ResolveMediaUrlAsync(string url)
|
||||
{
|
||||
if (!Request.ShouldDownloadMedia)
|
||||
@@ -63,10 +67,22 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
|
||||
try
|
||||
{
|
||||
var filePath = await _mediaDownloader.DownloadAsync(url).ConfigureAwait(false);
|
||||
var filePath = await _mediaDownloader.DownloadAsync(url);
|
||||
|
||||
// Return relative path so that the output files can be copied around without breaking
|
||||
return Path.GetRelativePath(Request.OutputBaseDirPath, filePath);
|
||||
// We want relative path so that the output files can be copied around without breaking
|
||||
var relativeFilePath = Path.GetRelativePath(Request.OutputBaseDirPath, filePath);
|
||||
|
||||
// For HTML, we need to format the URL properly
|
||||
if (Request.Format == ExportFormat.HtmlDark || Request.Format == ExportFormat.HtmlLight)
|
||||
{
|
||||
// Need to escape each path segment while keeping the directory separators intact
|
||||
return relativeFilePath
|
||||
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.Select(Uri.EscapeDataString)
|
||||
.JoinToString(Path.AltDirectorySeparatorChar.ToString());
|
||||
}
|
||||
|
||||
return relativeFilePath;
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Internal;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
@@ -127,8 +128,7 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
buffer.Append($".{format.GetFileExtension()}");
|
||||
|
||||
// Replace invalid chars
|
||||
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||
buffer.Replace(invalidChar, '_');
|
||||
PathEx.EscapePath(buffer);
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting
|
||||
{
|
||||
internal class MediaDownloader
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||
private readonly string _workingDirPath;
|
||||
@@ -21,32 +21,35 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
_workingDirPath = workingDirPath;
|
||||
}
|
||||
|
||||
private string GetRandomSuffix() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 8);
|
||||
|
||||
private string GetFileNameFromUrl(string url)
|
||||
{
|
||||
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(originalFileName))
|
||||
return GetRandomSuffix();
|
||||
|
||||
return $"{Path.GetFileNameWithoutExtension(originalFileName)}-{GetRandomSuffix()}{Path.GetExtension(originalFileName)}";
|
||||
}
|
||||
|
||||
// HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter
|
||||
public async ValueTask<string> DownloadAsync(string url)
|
||||
{
|
||||
if (_pathMap.TryGetValue(url, out var cachedFilePath))
|
||||
return cachedFilePath;
|
||||
|
||||
var fileName = GetFileNameFromUrl(url);
|
||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||
var filePath = PathEx.MakeUniqueFilePath(Path.Combine(_workingDirPath, fileName));
|
||||
|
||||
Directory.CreateDirectory(_workingDirPath);
|
||||
|
||||
await _httpClient.DownloadAsync(url, filePath).ConfigureAwait(false);
|
||||
await _httpClient.DownloadAsync(url, filePath);
|
||||
|
||||
return _pathMap[url] = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private static string GetRandomFileName() => Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16);
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
{
|
||||
var originalFileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
var fileName = !string.IsNullOrWhiteSpace(originalFileName)
|
||||
? $"{Path.GetFileNameWithoutExtension(originalFileName).Truncate(50)}{Path.GetExtension(originalFileName)}"
|
||||
: GetRandomFileName();
|
||||
|
||||
return PathEx.EscapePath(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
await _writer.WriteAsync(',');
|
||||
|
||||
// Message timestamp
|
||||
await _writer.WriteAsync(CsvEncode(message.Timestamp.ToLocalString(Context.Request.DateFormat)));
|
||||
await _writer.WriteAsync(CsvEncode(Context.FormatDate(message.Timestamp)));
|
||||
await _writer.WriteAsync(',');
|
||||
|
||||
// Message content
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
{{~ # Metadata ~}}
|
||||
<title>{{ Context.Request.Guild.Name | html.escape }} - {{ Context.Request.Channel.Name | html.escape }}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
|
||||
{{~ # Styles ~}}
|
||||
<style>
|
||||
{{ CoreStyleSheet }}
|
||||
</style>
|
||||
<style>
|
||||
{{ ThemeStyleSheet }}
|
||||
</style>
|
||||
|
||||
{{~ # Syntax highlighting ~}}
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/{{HighlightJsStyleName}}.min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
|
||||
});
|
||||
</script>
|
||||
|
||||
{{~ # Local scripts ~}}
|
||||
<script>
|
||||
function scrollToMessage(event, id) {
|
||||
var element = document.getElementById('message-' + id);
|
||||
|
||||
if (element) {
|
||||
event.preventDefault();
|
||||
|
||||
element.classList.add('chatlog__message--highlighted');
|
||||
|
||||
window.scrollTo({
|
||||
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
window.setTimeout(function() {
|
||||
element.classList.remove('chatlog__message--highlighted');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function showSpoiler(event, element) {
|
||||
if (element && element.classList.contains('spoiler--hidden')) {
|
||||
event.preventDefault();
|
||||
element.classList.remove('spoiler--hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{{~ # Preamble ~}}
|
||||
<div class="preamble">
|
||||
<div class="preamble__guild-icon-container">
|
||||
<img class="preamble__guild-icon" src="{{ Context.Request.Guild.IconUrl | ResolveUrl }}" alt="Guild icon">
|
||||
</div>
|
||||
<div class="preamble__entries-container">
|
||||
<div class="preamble__entry">{{ Context.Request.Guild.Name | html.escape }}</div>
|
||||
<div class="preamble__entry">{{ Context.Request.Channel.Category | html.escape }} / {{ Context.Request.Channel.Name | html.escape }}</div>
|
||||
|
||||
{{~ if Context.Request.Channel.Topic ~}}
|
||||
<div class="preamble__entry preamble__entry--small">{{ Context.Request.Channel.Topic | html.escape }}</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ if Context.Request.After || Context.Request.Before ~}}
|
||||
<div class="preamble__entry preamble__entry--small">
|
||||
{{~ if Context.Request.After && Context.Request.Before ~}}
|
||||
Between {{ Context.Request.After | FormatDate | html.escape }} and {{ Context.Request.Before | FormatDate | html.escape }}
|
||||
{{~ else if Context.Request.After ~}}
|
||||
After {{ Context.Request.After | FormatDate | html.escape }}
|
||||
{{~ else if Context.Request.Before ~}}
|
||||
Before {{ Context.Request.Before | FormatDate | html.escape }}
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{~ # Log ~}}
|
||||
<div class="chatlog">
|
||||
{{~ %SPLIT% ~}}
|
||||
</div>
|
||||
|
||||
{{~ # Postamble ~}}
|
||||
<div class="postamble">
|
||||
<div class="postamble__entry">Exported {{ MessageCount | object.format "N0" }} message(s)</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
{
|
||||
internal class LayoutTemplateContext
|
||||
{
|
||||
public ExportContext ExportContext { get; }
|
||||
|
||||
public string ThemeName { get; }
|
||||
|
||||
public long MessageCount { get; }
|
||||
|
||||
public LayoutTemplateContext(ExportContext exportContext, string themeName, long messageCount)
|
||||
{
|
||||
ExportContext = exportContext;
|
||||
ThemeName = themeName;
|
||||
MessageCount = messageCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
@using System
|
||||
@using System.Linq
|
||||
@using System.Threading.Tasks
|
||||
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.MessageGroupTemplateContext>
|
||||
|
||||
@{
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
|
||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||
|
||||
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
||||
|
||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||
|
||||
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
||||
var userNick = userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
||||
|
||||
var userColorStyle = userColor != null
|
||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
||||
: null;
|
||||
}
|
||||
|
||||
<div class="chatlog__message-group">
|
||||
<div class="chatlog__author-avatar-container">
|
||||
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar">
|
||||
</div>
|
||||
<div class="chatlog__messages">
|
||||
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
|
||||
|
||||
@if (Model.MessageGroup.Author.IsBot)
|
||||
{
|
||||
<span class="chatlog__bot-tag">BOT</span>
|
||||
}
|
||||
|
||||
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
|
||||
|
||||
@foreach (var message in Model.MessageGroup.Messages)
|
||||
{
|
||||
var isPinnedStyle = message.IsPinned ? "chatlog__message--pinned" : null;
|
||||
|
||||
<div class="chatlog__message @isPinnedStyle" data-message-id="@message.Id" id="message-@message.Id">
|
||||
<div class="chatlog__content">
|
||||
<div class="markdown">@Raw(FormatMarkdown(message.Content)) @if (message.EditedTimestamp != null) {<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>}</div>
|
||||
</div>
|
||||
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
<div class="chatlog__attachment">
|
||||
@if (attachment.IsSpoiler)
|
||||
{
|
||||
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
|
||||
<div class="spoiler-image">
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<img class="chatlog__attachment-thumbnail" src="@await ResolveUrlAsync(attachment.Url)" alt="Attachment">
|
||||
}
|
||||
else
|
||||
{
|
||||
@($"Attachment: {attachment.FileName} ({attachment.FileSize})")
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var embed in message.Embeds)
|
||||
{
|
||||
<div class="chatlog__embed">
|
||||
@if (embed.Color != null)
|
||||
{
|
||||
var embedColorStyle = $"background-color: rgba({embed.Color?.R},{embed.Color?.G},{embed.Color?.B},{embed.Color?.A})";
|
||||
<div class="chatlog__embed-color-pill" style="@embedColorStyle"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
}
|
||||
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
@if (embed.Author != null)
|
||||
{
|
||||
<div class="chatlog__embed-author">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconUrl)" alt="Author icon">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
{
|
||||
<span class="chatlog__embed-author-name">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||
{
|
||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
@embed.Author.Name
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||
{
|
||||
<div class="chatlog__embed-title">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||
{
|
||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
<div class="chatlog__embed-description">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (embed.Fields.Any())
|
||||
{
|
||||
<div class="chatlog__embed-fields">
|
||||
@foreach (var field in embed.Fields)
|
||||
{
|
||||
var isInlineStyle = field.IsInline ? "chatlog__embed-field--inline" : null;
|
||||
|
||||
<div class="chatlog__embed-field @isInlineStyle">
|
||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||
{
|
||||
<div class="chatlog__embed-field-name">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||
{
|
||||
<div class="chatlog__embed-field-value">
|
||||
<div class="markdown">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (embed.Thumbnail != null)
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.Url)" alt="Thumbnail">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (embed.Image != null)
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.Url)" alt="Image">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (embed.Footer != null || embed.Timestamp != null)
|
||||
{
|
||||
<div class="chatlog__embed-footer">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconUrl)" alt="Footer icon">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
|
||||
{
|
||||
@embed.Footer.Text
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp != null)
|
||||
{
|
||||
@(" • ")
|
||||
}
|
||||
|
||||
@if (embed.Timestamp != null)
|
||||
{
|
||||
@FormatDate(embed.Timestamp.Value)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (message.Reactions.Any())
|
||||
{
|
||||
<div class="chatlog__reactions">
|
||||
@foreach (var reaction in message.Reactions)
|
||||
{
|
||||
<div class="chatlog__reaction">
|
||||
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" title="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)">
|
||||
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,184 +0,0 @@
|
||||
<div class="chatlog__message-group">
|
||||
{{~ # Avatar ~}}
|
||||
<div class="chatlog__author-avatar-container">
|
||||
<img class="chatlog__author-avatar" src="{{ MessageGroup.Author.AvatarUrl | ResolveUrl }}" alt="Avatar">
|
||||
</div>
|
||||
<div class="chatlog__messages">
|
||||
{{~ # Author name and timestamp ~}}
|
||||
{{~ userColor = TryGetUserColor MessageGroup.Author | FormatColorRgb ~}}
|
||||
<span class="chatlog__author-name" title="{{ MessageGroup.Author.FullName | html.escape }}" data-user-id="{{ MessageGroup.Author.Id | html.escape }}" {{ if userColor }} style="color: {{ userColor }}" {{ end }}>{{ (TryGetUserNick MessageGroup.Author ?? MessageGroup.Author.Name) | html.escape }}</span>
|
||||
|
||||
{{~ # Bot tag ~}}
|
||||
{{~ if MessageGroup.Author.IsBot ~}}
|
||||
<span class="chatlog__bot-tag">BOT</span>
|
||||
{{~ end ~}}
|
||||
|
||||
<span class="chatlog__timestamp">{{ MessageGroup.Timestamp | FormatDate | html.escape }}</span>
|
||||
|
||||
{{~ # Messages ~}}
|
||||
{{~ for message in MessageGroup.Messages ~}}
|
||||
<div class="chatlog__message {{ if message.IsPinned }}chatlog__message--pinned{{ end }}" data-message-id="{{ message.Id }}" id="message-{{ message.Id }}">
|
||||
{{~ # Content ~}}
|
||||
{{~ if message.Content ~}}
|
||||
<div class="chatlog__content">
|
||||
<div class="markdown">
|
||||
{{- message.Content | FormatMarkdown -}}
|
||||
|
||||
{{- # Edited timestamp -}}
|
||||
{{- if message.EditedTimestamp -}}
|
||||
{{-}}<span class="chatlog__edited-timestamp" title="{{ message.EditedTimestamp | FormatDate | html.escape }}">(edited)</span>{{-}}
|
||||
{{- end -}}
|
||||
</div>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Attachments ~}}
|
||||
{{~ for attachment in message.Attachments ~}}
|
||||
<div class="chatlog__attachment">
|
||||
{{ # Spoiler image }}
|
||||
{{~ if attachment.IsSpoiler ~}}
|
||||
<div class="spoiler spoiler--hidden" onclick="showSpoiler(event, this)">
|
||||
<div class="spoiler-image">
|
||||
<a href="{{ attachment.Url | ResolveUrl }}">
|
||||
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url | ResolveUrl }}" alt="Attachment">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{~ else ~}}
|
||||
<a href="{{ attachment.Url | ResolveUrl }}">
|
||||
{{ # Non-spoiler image }}
|
||||
{{~ if attachment.IsImage ~}}
|
||||
<img class="chatlog__attachment-thumbnail" src="{{ attachment.Url | ResolveUrl }}" alt="Attachment">
|
||||
{{~ # Non-image ~}}
|
||||
{{~ else ~}}
|
||||
Attachment: {{ attachment.FileName }} ({{ attachment.FileSize }})
|
||||
{{~ end ~}}
|
||||
</a>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Embeds ~}}
|
||||
{{~ for embed in message.Embeds ~}}
|
||||
<div class="chatlog__embed">
|
||||
{{~ if embed.Color ~}}
|
||||
<div class="chatlog__embed-color-pill" style="background-color: rgba({{ embed.Color.R }},{{ embed.Color.G }},{{ embed.Color.B }},{{ embed.Color.A }})"></div>
|
||||
{{~ else ~}}
|
||||
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
|
||||
{{~ end ~}}
|
||||
<div class="chatlog__embed-content-container">
|
||||
<div class="chatlog__embed-content">
|
||||
<div class="chatlog__embed-text">
|
||||
{{~ # Author ~}}
|
||||
{{~ if embed.Author ~}}
|
||||
<div class="chatlog__embed-author">
|
||||
{{~ if embed.Author.IconUrl ~}}
|
||||
<img class="chatlog__embed-author-icon" src="{{ embed.Author.IconUrl | ResolveUrl }}" alt="Author icon">
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ if embed.Author.Name ~}}
|
||||
<span class="chatlog__embed-author-name">
|
||||
{{~ if embed.Author.Url ~}}
|
||||
<a class="chatlog__embed-author-name-link" href="{{ embed.Author.Url }}">{{ embed.Author.Name | html.escape }}</a>
|
||||
{{~ else ~}}
|
||||
{{ embed.Author.Name | html.escape }}
|
||||
{{~ end ~}}
|
||||
</span>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Title ~}}
|
||||
{{~ if embed.Title ~}}
|
||||
<div class="chatlog__embed-title">
|
||||
{{~ if embed.Url ~}}
|
||||
<a class="chatlog__embed-title-link" href="{{ embed.Url }}"><div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div></a>
|
||||
{{~ else ~}}
|
||||
<div class="markdown">{{ embed.Title | FormatEmbedMarkdown }}</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Description ~}}
|
||||
{{~ if embed.Description ~}}
|
||||
<div class="chatlog__embed-description"><div class="markdown">{{ embed.Description | FormatEmbedMarkdown }}</div></div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Fields ~}}
|
||||
{{~ if embed.Fields | array.size > 0 ~}}
|
||||
<div class="chatlog__embed-fields">
|
||||
{{~ for field in embed.Fields ~}}
|
||||
<div class="chatlog__embed-field {{ if field.IsInline }} chatlog__embed-field--inline {{ end }}">
|
||||
{{~ if field.Name ~}}
|
||||
<div class="chatlog__embed-field-name"><div class="markdown">{{ field.Name | FormatEmbedMarkdown }}</div></div>
|
||||
{{~ end ~}}
|
||||
{{~ if field.Value ~}}
|
||||
<div class="chatlog__embed-field-value"><div class="markdown">{{ field.Value | FormatEmbedMarkdown }}</div></div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
|
||||
{{~ # Thumbnail ~}}
|
||||
{{~ if embed.Thumbnail ~}}
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="{{ embed.Thumbnail.Url | ResolveUrl }}">
|
||||
<img class="chatlog__embed-thumbnail" src="{{ embed.Thumbnail.Url | ResolveUrl }}" alt="Thumbnail">
|
||||
</a>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
|
||||
{{~ # Image ~}}
|
||||
{{~ if embed.Image ~}}
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="{{ embed.Image.Url | ResolveUrl }}">
|
||||
<img class="chatlog__embed-image" src="{{ embed.Image.Url | ResolveUrl }}" alt="Image">
|
||||
</a>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Footer ~}}
|
||||
{{~ if embed.Footer || embed.Timestamp ~}}
|
||||
<div class="chatlog__embed-footer">
|
||||
{{~ if embed.Footer ~}}
|
||||
{{~ if embed.Footer.Text && embed.Footer.IconUrl ~}}
|
||||
<img class="chatlog__embed-footer-icon" src="{{ embed.Footer.IconUrl | ResolveUrl }}" alt="Footer icon">
|
||||
{{~ end ~}}
|
||||
{{~ end ~}}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
{{~ if embed.Footer ~}}
|
||||
{{~ if embed.Footer.Text ~}}
|
||||
{{ embed.Footer.Text | html.escape }}
|
||||
{{ if embed.Timestamp }} • {{ end }}
|
||||
{{~ end ~}}
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ if embed.Timestamp ~}}
|
||||
{{ embed.Timestamp | FormatDate | html.escape }}
|
||||
{{~ end ~}}
|
||||
</span>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
|
||||
{{~ # Reactions ~}}
|
||||
{{~ if message.Reactions | array.size > 0 ~}}
|
||||
<div class="chatlog__reactions">
|
||||
{{~ for reaction in message.Reactions ~}}
|
||||
<div class="chatlog__reaction">
|
||||
<img class="emoji emoji--small" alt="{{ reaction.Emoji.Name }}" title="{{ reaction.Emoji.Name }}" src="{{ reaction.Emoji.ImageUrl | ResolveUrl }}">
|
||||
<span class="chatlog__reaction-count">{{ reaction.Count }}</span>
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
{{~ end ~}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
{
|
||||
internal class MessageGroupTemplateContext
|
||||
{
|
||||
public ExportContext ExportContext { get; }
|
||||
|
||||
public MessageGroup MessageGroup { get; }
|
||||
|
||||
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
|
||||
{
|
||||
ExportContext = exportContext;
|
||||
MessageGroup = messageGroup;
|
||||
}
|
||||
|
||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="postamble">
|
||||
<div class="postamble__entry">Exported @Model.MessageCount.ToString("N0") message(s)</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,100 @@
|
||||
@using System
|
||||
@using System.Threading.Tasks
|
||||
@using Tyrrrz.Extensions
|
||||
@inherits MiniRazor.MiniRazorTemplateBase<DiscordChatExporter.Domain.Exporting.Writers.Html.LayoutTemplateContext>
|
||||
|
||||
@{
|
||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||
|
||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||
|
||||
string GetStyleSheet(string name) => Model.GetType().Assembly.GetManifestResourceString($"DiscordChatExporter.Domain.Exporting.Writers.Html.{name}.css");
|
||||
}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>@Model.ExportContext.Request.Guild.Name - @Model.ExportContext.Request.Channel.Name</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
|
||||
<style>
|
||||
@Raw(GetStyleSheet("Core"))
|
||||
</style>
|
||||
<style>
|
||||
@Raw(GetStyleSheet(Model.ThemeName))
|
||||
</style>
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
function scrollToMessage(event, id) {
|
||||
var element = document.getElementById('message-' + id);
|
||||
|
||||
if (element) {
|
||||
event.preventDefault();
|
||||
|
||||
element.classList.add('chatlog__message--highlighted');
|
||||
|
||||
window.scrollTo({
|
||||
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
window.setTimeout(function() {
|
||||
element.classList.remove('chatlog__message--highlighted');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function showSpoiler(event, element) {
|
||||
if (element && element.classList.contains('spoiler--hidden')) {
|
||||
event.preventDefault();
|
||||
element.classList.remove('spoiler--hidden');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="preamble">
|
||||
<div class="preamble__guild-icon-container">
|
||||
<img class="preamble__guild-icon" src="@await ResolveUrlAsync(Model.ExportContext.Request.Guild.IconUrl)" alt="Guild icon">
|
||||
</div>
|
||||
<div class="preamble__entries-container">
|
||||
<div class="preamble__entry">@Model.ExportContext.Request.Guild.Name</div>
|
||||
<div class="preamble__entry">@Model.ExportContext.Request.Channel.Category / @Model.ExportContext.Request.Channel.Name</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic))
|
||||
{
|
||||
<div class="preamble__entry preamble__entry--small">@Model.ExportContext.Request.Channel.Topic</div>
|
||||
}
|
||||
|
||||
@if (Model.ExportContext.Request.After != null || Model.ExportContext.Request.Before != null)
|
||||
{
|
||||
<div class="preamble__entry preamble__entry--small">
|
||||
@if (Model.ExportContext.Request.After != null && Model.ExportContext.Request.Before != null)
|
||||
{
|
||||
@($"Between {FormatDate(Model.ExportContext.Request.After.Value)} and {FormatDate(Model.ExportContext.Request.Before.Value)}")
|
||||
}
|
||||
else if (Model.ExportContext.Request.After != null)
|
||||
{
|
||||
@($"After {FormatDate(Model.ExportContext.Request.After.Value)}")
|
||||
}
|
||||
else if (Model.ExportContext.Request.Before != null)
|
||||
{
|
||||
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value)}")
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chatlog">
|
||||
@@ -0,0 +1,59 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
using MiniRazor;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
[assembly: InternalsVisibleTo("RazorAssembly")]
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
|
||||
{
|
||||
internal partial class TemplateBundle
|
||||
{
|
||||
public MiniRazorTemplateDescriptor PreambleTemplate { get; }
|
||||
|
||||
public MiniRazorTemplateDescriptor MessageGroupTemplate { get; }
|
||||
|
||||
public MiniRazorTemplateDescriptor PostambleTemplate { get; }
|
||||
|
||||
public TemplateBundle(
|
||||
MiniRazorTemplateDescriptor preambleTemplate,
|
||||
MiniRazorTemplateDescriptor messageGroupTemplate,
|
||||
MiniRazorTemplateDescriptor postambleTemplate)
|
||||
{
|
||||
PreambleTemplate = preambleTemplate;
|
||||
MessageGroupTemplate = messageGroupTemplate;
|
||||
PostambleTemplate = postambleTemplate;
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class TemplateBundle
|
||||
{
|
||||
private static TemplateBundle? _lastBundle;
|
||||
|
||||
// This is very CPU-heavy
|
||||
private static async ValueTask<TemplateBundle> CompileAsync() => await Task.Run(() =>
|
||||
{
|
||||
var ns = typeof(TemplateBundle).Namespace!;
|
||||
|
||||
var preambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||
.GetManifestResourceString($"{ns}.PreambleTemplate.cshtml");
|
||||
|
||||
var messageGroupTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||
.GetManifestResourceString($"{ns}.MessageGroupTemplate.cshtml");
|
||||
|
||||
var postambleTemplateSource = typeof(HtmlMessageWriter).Assembly
|
||||
.GetManifestResourceString($"{ns}.PostambleTemplate.cshtml");
|
||||
|
||||
var engine = new MiniRazorTemplateEngine("RazorAssembly", ns);
|
||||
|
||||
var preambleTemplate = engine.Compile(preambleTemplateSource);
|
||||
var messageGroupTemplate = engine.Compile(messageGroupTemplateSource);
|
||||
var postambleTemplate = engine.Compile(postambleTemplateSource);
|
||||
|
||||
return new TemplateBundle(preambleTemplate, messageGroupTemplate, postambleTemplate);
|
||||
});
|
||||
|
||||
public static async ValueTask<TemplateBundle> ResolveAsync() =>
|
||||
_lastBundle ??= await CompileAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.Html;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Scriban;
|
||||
using Scriban.Runtime;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
internal partial class HtmlMessageWriter : MessageWriter
|
||||
internal class HtmlMessageWriter : MessageWriter
|
||||
{
|
||||
private readonly TextWriter _writer;
|
||||
private readonly string _themeName;
|
||||
|
||||
private readonly List<Message> _messageGroupBuffer = new List<Message>();
|
||||
|
||||
private readonly Template _preambleTemplate;
|
||||
private readonly Template _messageGroupTemplate;
|
||||
private readonly Template _postambleTemplate;
|
||||
|
||||
private long _messageCount;
|
||||
|
||||
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
||||
@@ -33,89 +21,26 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
_writer = new StreamWriter(stream);
|
||||
_themeName = themeName;
|
||||
|
||||
_preambleTemplate = Template.Parse(GetPreambleTemplateCode());
|
||||
_messageGroupTemplate = Template.Parse(GetMessageGroupTemplateCode());
|
||||
_postambleTemplate = Template.Parse(GetPostambleTemplateCode());
|
||||
}
|
||||
|
||||
private TemplateContext CreateTemplateContext(IReadOnlyDictionary<string, object>? constants = null)
|
||||
{
|
||||
// Template context
|
||||
var templateContext = new TemplateContext
|
||||
{
|
||||
MemberRenamer = m => m.Name,
|
||||
MemberFilter = m => true,
|
||||
LoopLimit = int.MaxValue,
|
||||
StrictVariables = true
|
||||
};
|
||||
|
||||
// Model
|
||||
var scriptObject = new ScriptObject();
|
||||
|
||||
// Constants
|
||||
scriptObject.SetValue("Context", Context, true);
|
||||
scriptObject.SetValue("CoreStyleSheet", GetCoreStyleSheetCode(), true);
|
||||
scriptObject.SetValue("ThemeStyleSheet", GetThemeStyleSheetCode(_themeName), true);
|
||||
scriptObject.SetValue("HighlightJsStyleName", $"solarized-{_themeName.ToLowerInvariant()}", true);
|
||||
|
||||
// Additional constants
|
||||
if (constants != null)
|
||||
{
|
||||
foreach (var (member, value) in constants)
|
||||
scriptObject.SetValue(member, value, true);
|
||||
}
|
||||
|
||||
// Functions
|
||||
scriptObject.Import("FormatDate",
|
||||
new Func<DateTimeOffset, string>(d => d.ToLocalString(Context.Request.DateFormat)));
|
||||
|
||||
scriptObject.Import("FormatColorRgb",
|
||||
new Func<Color?, string?>(c => c != null ? $"rgb({c?.R}, {c?.G}, {c?.B})" : null));
|
||||
|
||||
scriptObject.Import("TryGetUserColor",
|
||||
new Func<User, Color?>(Context.TryGetUserColor));
|
||||
|
||||
scriptObject.Import("TryGetUserNick",
|
||||
new Func<User, string?>(u => Context.TryGetMember(u.Id)?.Nick));
|
||||
|
||||
scriptObject.Import("FormatMarkdown",
|
||||
new Func<string?, string>(m => FormatMarkdown(m)));
|
||||
|
||||
scriptObject.Import("FormatEmbedMarkdown",
|
||||
new Func<string?, string>(m => FormatMarkdown(m, false)));
|
||||
|
||||
// HACK: Scriban doesn't support async, so we have to resort to this and be careful about deadlocks.
|
||||
// TODO: move to Razor.
|
||||
scriptObject.Import("ResolveUrl",
|
||||
new Func<string, string>(u => Context.ResolveMediaUrlAsync(u).GetAwaiter().GetResult()));
|
||||
|
||||
// Push model
|
||||
templateContext.PushGlobal(scriptObject);
|
||||
|
||||
// Push output
|
||||
templateContext.PushOutput(new TextWriterOutput(_writer));
|
||||
|
||||
return templateContext;
|
||||
}
|
||||
|
||||
private string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||
HtmlMarkdownVisitor.Format(Context, markdown ?? "", isJumboAllowed);
|
||||
|
||||
private async ValueTask WriteCurrentMessageGroupAsync()
|
||||
{
|
||||
var templateContext = CreateTemplateContext(new Dictionary<string, object>
|
||||
{
|
||||
["MessageGroup"] = MessageGroup.Join(_messageGroupBuffer)
|
||||
});
|
||||
|
||||
await templateContext.EvaluateAsync(_messageGroupTemplate.Page);
|
||||
}
|
||||
|
||||
public override async ValueTask WritePreambleAsync()
|
||||
{
|
||||
var templateContext = CreateTemplateContext();
|
||||
await templateContext.EvaluateAsync(_preambleTemplate.Page);
|
||||
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
await templateBundle.PreambleTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
}
|
||||
|
||||
private async ValueTask WriteMessageGroupAsync(MessageGroup messageGroup)
|
||||
{
|
||||
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
||||
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
await templateBundle.MessageGroupTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
}
|
||||
|
||||
public override async ValueTask WriteMessageAsync(Message message)
|
||||
@@ -128,7 +53,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
// Otherwise, flush the group and render messages
|
||||
else
|
||||
{
|
||||
await WriteCurrentMessageGroupAsync();
|
||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
|
||||
|
||||
_messageGroupBuffer.Clear();
|
||||
_messageGroupBuffer.Add(message);
|
||||
@@ -142,14 +67,14 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
{
|
||||
// Flush current message group
|
||||
if (_messageGroupBuffer.Any())
|
||||
await WriteCurrentMessageGroupAsync();
|
||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer));
|
||||
|
||||
var templateContext = CreateTemplateContext(new Dictionary<string, object>
|
||||
{
|
||||
["MessageCount"] = _messageCount
|
||||
});
|
||||
var templateContext = new LayoutTemplateContext(Context, _themeName, _messageCount);
|
||||
var templateBundle = await TemplateBundle.ResolveAsync();
|
||||
|
||||
await templateContext.EvaluateAsync(_postambleTemplate.Page);
|
||||
await _writer.WriteLineAsync(
|
||||
await templateBundle.PostambleTemplate.RenderAsync(templateContext)
|
||||
);
|
||||
}
|
||||
|
||||
public override async ValueTask DisposeAsync()
|
||||
@@ -158,32 +83,4 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class HtmlMessageWriter
|
||||
{
|
||||
private static readonly Assembly ResourcesAssembly = typeof(HtmlMessageWriter).Assembly;
|
||||
private static readonly string ResourcesNamespace = $"{ResourcesAssembly.GetName().Name}.Exporting.Writers.Html";
|
||||
|
||||
private static string GetCoreStyleSheetCode() =>
|
||||
ResourcesAssembly
|
||||
.GetManifestResourceString($"{ResourcesNamespace}.Core.css");
|
||||
|
||||
private static string GetThemeStyleSheetCode(string themeName) =>
|
||||
ResourcesAssembly
|
||||
.GetManifestResourceString($"{ResourcesNamespace}.{themeName}.css");
|
||||
|
||||
private static string GetPreambleTemplateCode() =>
|
||||
ResourcesAssembly
|
||||
.GetManifestResourceString($"{ResourcesNamespace}.LayoutTemplate.html")
|
||||
.SubstringUntil("{{~ %SPLIT% ~}}");
|
||||
|
||||
private static string GetMessageGroupTemplateCode() =>
|
||||
ResourcesAssembly
|
||||
.GetManifestResourceString($"{ResourcesNamespace}.MessageGroupTemplate.html");
|
||||
|
||||
private static string GetPostambleTemplateCode() =>
|
||||
ResourcesAssembly
|
||||
.GetManifestResourceString($"{ResourcesNamespace}.LayoutTemplate.html")
|
||||
.SubstringAfter("{{~ %SPLIT% ~}}");
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Domain.Discord.Models;
|
||||
using DiscordChatExporter.Domain.Exporting.Writers.MarkdownVisitors;
|
||||
using DiscordChatExporter.Domain.Internal.Extensions;
|
||||
using Tyrrrz.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
@@ -27,7 +26,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
private async ValueTask WriteMessageHeaderAsync(Message message)
|
||||
{
|
||||
// Timestamp & author
|
||||
await _writer.WriteAsync($"[{message.Timestamp.ToLocalString(Context.Request.DateFormat)}]");
|
||||
await _writer.WriteAsync($"[{Context.FormatDate(message.Timestamp)}]");
|
||||
await _writer.WriteAsync($" {message.Author.FullName}");
|
||||
|
||||
// Whether the message is pinned
|
||||
@@ -120,10 +119,10 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
|
||||
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
|
||||
|
||||
if (Context.Request.After != null)
|
||||
await _writer.WriteLineAsync($"After: {Context.Request.After.Value.ToLocalString(Context.Request.DateFormat)}");
|
||||
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value)}");
|
||||
|
||||
if (Context.Request.Before != null)
|
||||
await _writer.WriteLineAsync($"Before: {Context.Request.Before.Value.ToLocalString(Context.Request.DateFormat)}");
|
||||
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value)}");
|
||||
|
||||
await _writer.WriteLineAsync('='.Repeat(62));
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
@@ -7,14 +7,13 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class HttpClientExtensions
|
||||
{
|
||||
// HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter
|
||||
public static async ValueTask DownloadAsync(this HttpClient httpClient, string uri, string outputFilePath)
|
||||
{
|
||||
await using var input = await httpClient.GetStreamAsync(uri).ConfigureAwait(false);
|
||||
await using var input = await httpClient.GetStreamAsync(uri);
|
||||
var output = File.Create(outputFilePath);
|
||||
|
||||
await input.CopyToAsync(output).ConfigureAwait(false);
|
||||
await output.DisposeAsync().ConfigureAwait(false);
|
||||
await input.CopyToAsync(output);
|
||||
await output.DisposeAsync();
|
||||
}
|
||||
|
||||
public static async ValueTask<JsonElement> ReadAsJsonAsync(this HttpContent content)
|
||||
|
||||
@@ -4,6 +4,11 @@ namespace DiscordChatExporter.Domain.Internal.Extensions
|
||||
{
|
||||
internal static class StringExtensions
|
||||
{
|
||||
public static string Truncate(this string str, int charCount) =>
|
||||
str.Length > charCount
|
||||
? str.Substring(0, charCount)
|
||||
: str;
|
||||
|
||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||
builder.Length > 0
|
||||
? builder.Append(value)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Domain.Internal
|
||||
{
|
||||
internal static class PathEx
|
||||
{
|
||||
public static StringBuilder EscapePath(StringBuilder pathBuffer)
|
||||
{
|
||||
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||
pathBuffer.Replace(invalidChar, '_');
|
||||
|
||||
return pathBuffer;
|
||||
}
|
||||
|
||||
public static string EscapePath(string path) => EscapePath(new StringBuilder(path)).ToString();
|
||||
|
||||
public static string MakeUniqueFilePath(string baseFilePath, int maxAttempts = int.MaxValue)
|
||||
{
|
||||
if (!File.Exists(baseFilePath))
|
||||
return baseFilePath;
|
||||
|
||||
var baseDirPath = Path.GetDirectoryName(baseFilePath);
|
||||
var baseFileNameWithoutExtension = Path.GetFileNameWithoutExtension(baseFilePath);
|
||||
var baseFileExtension = Path.GetExtension(baseFilePath);
|
||||
|
||||
for (var i = 1; i <= maxAttempts; i++)
|
||||
{
|
||||
var filePath = $"{baseFileNameWithoutExtension} ({i}){baseFileExtension}";
|
||||
if (!string.IsNullOrWhiteSpace(baseDirPath))
|
||||
filePath = Path.Combine(baseDirPath, filePath);
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return baseFilePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
<PackageReference Include="MaterialDesignThemes" Version="3.0.1" />
|
||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.19" />
|
||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="1.1.0" />
|
||||
<PackageReference Include="Onova" Version="2.6.0" />
|
||||
<PackageReference Include="Stylet" Version="1.3.3" />
|
||||
<PackageReference Include="Onova" Version="2.6.1" />
|
||||
<PackageReference Include="Stylet" Version="1.3.4" />
|
||||
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
|
||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||
<PackageReference Include="PropertyChanged.Fody" Version="3.2.8" PrivateAssets="all" />
|
||||
|
||||
@@ -134,7 +134,7 @@ namespace DiscordChatExporter.Gui.ViewModels
|
||||
|
||||
try
|
||||
{
|
||||
var tokenValue = TokenValue?.Trim('"');
|
||||
var tokenValue = TokenValue?.Trim('"', ' ');
|
||||
if (string.IsNullOrWhiteSpace(tokenValue))
|
||||
return;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp3.1</TargetFramework>
|
||||
<Version>2.21.1</Version>
|
||||
<Version>2.21.2</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
||||
<LangVersion>latest</LangVersion>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||
[](https://tyrrrz.me/donate)
|
||||
[](https://xscode.com/Tyrrrz/DiscordChatExporter)
|
||||
|
||||
DiscordChatExporter can be used to export message history from a [Discord](https://discord.com) channel to a file. It works with direct messages, group messages, server channels, supports Discord's dialect of markdown and all other rich media features.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user