mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 06:39:33 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da6d903f36 | |||
| e1b1755fba | |||
| 1ff026eba6 | |||
| 1fe4ecb3af | |||
| 47a1518cd9 | |||
| d78c10ebb7 | |||
| 3db29b520c | |||
| 752003abc3 | |||
| e26a0660d1 | |||
| 7dfc3b2723 | |||
| 06c33373de | |||
| 563f5cb67b | |||
| 1f20915b4d | |||
| e58c7aefff |
@@ -1,3 +1,17 @@
|
||||
### 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.
|
||||
- Changed the naming convention for downloaded media files so that the original file names are used when possible.
|
||||
|
||||
### v2.21 (18-Jul-2020)
|
||||
|
||||
- Added a new option that enables self-contained exports for all output formats. You can turn it on in the export setup dialog in GUI or using the `--media` option in CLI. When using this, the application will additionally download any media content directly referenced from the exported file instead of linking back to Discord CDN. The files which are downloaded include: guild icons, user avatars, attachments, embedded images, reaction emojis. Note that only files which are actually referenced by the export are downloaded, which means that, for example, user avatars will not be downloaded when using plain text export format. This option is not meant to enable complete offline viewing for HTML exports, but rather to make it easier to archive media content that may eventually get deleted from Discord servers. Also keep in mind that this option may make the export drastically slower and the total file size larger.
|
||||
|
||||
@@ -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,9 +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
|
||||
{
|
||||
@@ -33,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);
|
||||
|
||||
@@ -42,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?
|
||||
@@ -54,16 +60,35 @@ 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)
|
||||
return url;
|
||||
|
||||
var filePath = await _mediaDownloader.DownloadAsync(url).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
// We don't want this to crash the exporting process in case of failure
|
||||
return url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -14,34 +14,42 @@ namespace DiscordChatExporter.Domain.Exporting
|
||||
private readonly HttpClient _httpClient = Singleton.HttpClient;
|
||||
private readonly string _workingDirPath;
|
||||
|
||||
private readonly Dictionary<string, string> _mediaPathMap = new Dictionary<string, string>();
|
||||
private readonly Dictionary<string, string> _pathMap = new Dictionary<string, string>();
|
||||
|
||||
public MediaDownloader(string workingDirPath)
|
||||
{
|
||||
_workingDirPath = workingDirPath;
|
||||
}
|
||||
|
||||
// HACK: ConfigureAwait() is crucial here to enable sync-over-async in HtmlMessageWriter
|
||||
public async ValueTask<string> DownloadAsync(string url)
|
||||
{
|
||||
if (_mediaPathMap.TryGetValue(url, out var cachedFilePath))
|
||||
if (_pathMap.TryGetValue(url, out var cachedFilePath))
|
||||
return cachedFilePath;
|
||||
|
||||
var fileName = GetFileNameFromUrl(url);
|
||||
var filePath = PathEx.MakeUniqueFilePath(Path.Combine(_workingDirPath, fileName));
|
||||
|
||||
Directory.CreateDirectory(_workingDirPath);
|
||||
|
||||
var extension = Path.GetExtension(GetFileNameFromUrl(url));
|
||||
var fileName = $"{Guid.NewGuid()}{extension}";
|
||||
var filePath = Path.Combine(_workingDirPath, fileName);
|
||||
await _httpClient.DownloadAsync(url, filePath);
|
||||
|
||||
await _httpClient.DownloadAsync(url, filePath).ConfigureAwait(false);
|
||||
|
||||
return _mediaPathMap[url] = filePath;
|
||||
return _pathMap[url] = filePath;
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class MediaDownloader
|
||||
{
|
||||
private static string GetFileNameFromUrl(string url) =>
|
||||
Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
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</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