Compare commits

..

27 Commits

Author SHA1 Message Date
Alexey Golub 921f348769 Update version 2020-09-14 15:37:36 +03:00
Alexey Golub a25e809671 Extend try/catch in media downloading to OperationCanceledException
Closes #372
2020-09-14 15:09:57 +03:00
Alexey Golub fa80c82468 Cleanup 2020-09-05 17:10:15 +03:00
Alexey Golub 91f4f02a35 Refactor last commit 2020-09-05 17:05:18 +03:00
wyattscarpenter 6d2880ce26 [CLI] Add command to export all channels across all guilds (#373) 2020-09-05 00:23:06 +03:00
Ahmed Massoud 355b8cb8cf Add call time for call status messages (#365) 2020-08-22 22:14:13 +03:00
Alexey Golub 82945ac3cf [HTML] Use CDN for Whitney fonts
Related to #322
2020-08-21 20:36:59 +03:00
Alexey Golub 5009b90a3e Update screenshots 2020-08-15 13:47:47 +03:00
Alexey Golub 1495ed9b90 Update version 2020-08-12 19:21:24 +03:00
Alexey Golub be66c74c08 Update nuget packages 2020-08-12 19:11:18 +03:00
Alexey Golub 6f54c09d96 [GUI] Refactor sorting 2020-08-12 19:09:12 +03:00
Alexey Golub 45926b0990 Refactor last commit 2020-08-07 21:41:10 +03:00
CarJem Generations 25d29dc079 [GUI] Add controls to configure export time limits in addition to dates (#355) 2020-08-07 21:06:49 +03:00
Alexey Golub efde9931c9 Refactor & optimize changes in last commit 2020-08-07 21:00:43 +03:00
CarJem Generations 2c3b461d49 [GUI] Make categories collapsible in channel list (#354) 2020-08-07 19:42:59 +03:00
CarJem Generations b405052fd6 [HTML] Workaround for Tupperbox nicknames (#352) 2020-08-05 21:58:17 +03:00
Alexey Golub da6d903f36 Update version 2020-07-30 16:03:27 +03:00
Alexey Golub e1b1755fba Don't escape media URL on non-HTML formats 2020-07-30 16:02:00 +03:00
Alexey Golub 1ff026eba6 [GUI] Trim spaces in token input 2020-07-29 20:58:49 +03:00
Alexey Golub 1fe4ecb3af Truncate long file names in MediaDownloader
Fixes #344
2020-07-29 20:58:38 +03:00
Alexey Golub 47a1518cd9 Update nuget packages 2020-07-29 20:49:08 +03:00
Alexey Golub d78c10ebb7 Remove default cap on MakeUniqueFilePath 2020-07-29 20:47:58 +03:00
Alexey Golub 3db29b520c Switch from RazorLight to MiniRazor 2020-07-29 20:08:51 +03:00
Alexey Golub 752003abc3 Fix media downloading 2020-07-28 23:07:31 +03:00
Alexey Golub e26a0660d1 Use Razor instead of Scriban 2020-07-28 22:43:45 +03:00
Alexey Golub 7dfc3b2723 Add link to extra services in readme 2020-07-26 15:54:30 +03:00
Alexey Golub 06c33373de Sanitize file names when exporting media
Fixes #332
2020-07-25 21:17:05 +03:00
39 changed files with 868 additions and 497 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+22
View File
@@ -1,3 +1,25 @@
### v2.23 (14-Sep-2020)
- [CLI] Added a command to export all channels across all servers. Use `exportall` to do it. (Thanks [@wyattscarpenter](https://github.com/wyattscarpenter))
- [HTML] Fixed an issue where Whitney fonts were not being loaded properly, causing the browser to fall back to Helvetica.
- Fixed an issue where self-contained export crashed occasionally. This usually happened when the server hosting the file did not serve the stream properly. Such files are now ignored after the first failed attempt.
### v2.22 (12-Aug-2020)
- [GUI] Improved the channel list by adding collapsible category groups. (Thanks [@CarJem Generations](https://github.com/CarJem))
- [GUI] Improved exporting options by adding a set of controls that can be used to limit the date range of the export down to minutes. Previously it was only possible to configure the date range without the time component. (Thanks [@CarJem Generations](https://github.com/CarJem))
- [HTML] Fixed an issue where the export didn't reflect changes in nicknames between messages sent by bots. This affected chat logs that contained interactions with the Tupperbox bot. (Thanks [@CarJem Generations](https://github.com/CarJem))
- [CLI] Fixed an issue where the application crashed if there were two environment variables defined that had the same name but in different case.
### 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.
@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Domain.Discord.Models;
namespace DiscordChatExporter.Cli.Commands
{
[Command("exportall", Description = "Export all accessible channels.")]
public class ExportAllCommand : ExportMultipleCommandBase
{
[CommandOption("include-dm", Description = "Whether to also export direct message channels.")]
public bool IncludeDirectMessages { get; set; } = true;
public override async ValueTask ExecuteAsync(IConsole console)
{
var channels = new List<Channel>();
// Aggregate channels from all guilds
await foreach (var guild in GetDiscordClient().GetUserGuildsAsync())
{
// Skip DMs if instructed to
if (!IncludeDirectMessages && guild.Id == Guild.DirectMessages.Id)
continue;
await foreach (var channel in GetDiscordClient().GetGuildChannelsAsync(guild.Id))
{
channels.Add(channel);
}
}
await ExportMultipleAsync(console, channels);
}
}
}
@@ -12,8 +12,8 @@ namespace DiscordChatExporter.Cli.Commands
{
public override async ValueTask ExecuteAsync(IConsole console)
{
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
await ExportMultipleAsync(console, dmChannels);
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
await ExportMultipleAsync(console, channels);
}
}
}
@@ -14,8 +14,8 @@ namespace DiscordChatExporter.Cli.Commands
public override async ValueTask ExecuteAsync(IConsole console)
{
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
await ExportMultipleAsync(console, guildChannels);
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
await ExportMultipleAsync(console, channels);
}
}
}
@@ -7,7 +7,7 @@ using DiscordChatExporter.Domain.Utilities;
namespace DiscordChatExporter.Cli.Commands
{
[Command("channels", Description = "Get the list of channels in specified guild.")]
[Command("channels", Description = "Get the list of channels in a guild.")]
public class GetChannelsCommand : TokenCommandBase
{
[CommandOption("guild", 'g', IsRequired = true, Description = "Guild ID.")]
@@ -15,9 +15,9 @@ namespace DiscordChatExporter.Cli.Commands
public override async ValueTask ExecuteAsync(IConsole console)
{
var guildChannels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
var channels = await GetDiscordClient().GetGuildChannelsAsync(GuildId);
foreach (var channel in guildChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
}
}
@@ -13,9 +13,9 @@ namespace DiscordChatExporter.Cli.Commands
{
public override async ValueTask ExecuteAsync(IConsole console)
{
var dmChannels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
var channels = await GetDiscordClient().GetGuildChannelsAsync(Guild.DirectMessages.Id);
foreach (var channel in dmChannels.OrderBy(c => c.Category).ThenBy(c => c.Name))
foreach (var channel in channels.OrderBy(c => c.Category).ThenBy(c => c.Name))
console.Output.WriteLine($"{channel.Id} | {channel.Category} / {channel.Name}");
}
}
@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="1.2.0" />
<PackageReference Include="CliFx" Version="1.3.2" />
<PackageReference Include="Gress" Version="1.2.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
</ItemGroup>
@@ -33,6 +33,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
public DateTimeOffset? EditedTimestamp { get; }
public DateTimeOffset? CallEndedTimestamp { get; }
public bool IsPinned { get; }
public string Content { get; }
@@ -51,6 +53,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
User author,
DateTimeOffset timestamp,
DateTimeOffset? editedTimestamp,
DateTimeOffset? callEndedTimestamp,
bool isPinned,
string content,
IReadOnlyList<Attachment> attachments,
@@ -63,6 +66,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
Author = author;
Timestamp = timestamp;
EditedTimestamp = editedTimestamp;
CallEndedTimestamp = callEndedTimestamp;
IsPinned = isPinned;
Content = content;
Attachments = attachments;
@@ -82,6 +86,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
var author = json.GetProperty("author").Pipe(User.Parse);
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffset();
var callEndedTimestamp = json.GetPropertyOrNull("call")?.GetPropertyOrNull("ended_timestamp")?.GetDateTimeOffset();
var type = (MessageType) json.GetProperty("type").GetInt32();
var isPinned = json.GetPropertyOrNull("pinned")?.GetBoolean() ?? false;
@@ -89,7 +94,8 @@ namespace DiscordChatExporter.Domain.Discord.Models
{
MessageType.RecipientAdd => "Added a recipient.",
MessageType.RecipientRemove => "Removed a recipient.",
MessageType.Call => "Started a call.",
MessageType.Call =>
$"Started a call that lasted {callEndedTimestamp?.Pipe(t => t - timestamp).Pipe(t => (int) t.TotalMinutes) ?? 0} minutes.",
MessageType.ChannelNameChange => "Changed the channel name.",
MessageType.ChannelIconChange => "Changed the channel icon.",
MessageType.ChannelPinnedMessage => "Pinned a message.",
@@ -119,6 +125,7 @@ namespace DiscordChatExporter.Domain.Discord.Models
author,
timestamp,
editedTimestamp,
callEndedTimestamp,
isPinned,
content,
attachments,
@@ -2,17 +2,18 @@
<Import Project="../DiscordChatExporter.props" />
<ItemGroup>
<PackageReference Include="MiniRazor" Version="1.1.0" />
<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,12 +67,27 @@ 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)
// Try to catch only exceptions related to failed HTTP requests
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
catch (Exception ex) when (ex is HttpRequestException || ex is OperationCanceledException)
{
// 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();
}
@@ -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
@@ -2,31 +2,31 @@
@font-face {
font-family: Whitney;
src: url(https://discord.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff);
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
font-weight: 300;
}
@font-face {
font-family: Whitney;
src: url(https://discord.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff);
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
font-weight: 400;
}
@font-face {
font-family: Whitney;
src: url(https://discord.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff);
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
font-weight: 500;
}
@font-face {
font-family: Whitney;
src: url(https://discord.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff);
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
font-weight: 600;
}
@font-face {
font-family: Whitney;
src: url(https://discord.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff);
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
font-weight: 700;
}
@@ -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 = (Model.MessageGroup.Author.IsBot ? Model.MessageGroup.Author.Name : (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,65 @@
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using MiniRazor;
using Tyrrrz.Extensions;
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PreambleTemplateAssemblyName)]
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.MessageGroupTemplateAssemblyName)]
[assembly: InternalsVisibleTo(DiscordChatExporter.Domain.Exporting.Writers.Html.TemplateBundle.PostambleTemplateAssemblyName)]
namespace DiscordChatExporter.Domain.Exporting.Writers.Html
{
internal partial class TemplateBundle
{
public const string PreambleTemplateAssemblyName = "RazorAssembly_Preamble";
public const string MessageGroupTemplateAssemblyName = "RazorAssembly_MessageGroup";
public const string PostambleTemplateAssemblyName = "RazorAssembly_Postamble";
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();
var preambleTemplate = engine.Compile(preambleTemplateSource, PreambleTemplateAssemblyName, ns);
var messageGroupTemplate = engine.Compile(messageGroupTemplateSource, MessageGroupTemplateAssemblyName, ns);
var postambleTemplate = engine.Compile(postambleTemplateSource, PostambleTemplateAssemblyName, ns);
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% ~}}");
}
}
@@ -197,6 +197,7 @@ namespace DiscordChatExporter.Domain.Exporting.Writers
_writer.WriteString("type", message.Type.ToString());
_writer.WriteString("timestamp", message.Timestamp);
_writer.WriteString("timestampEdited", message.EditedTimestamp);
_writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp);
_writer.WriteBoolean("isPinned", message.IsPinned);
// Content
@@ -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;
}
}
}
+88
View File
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:s="https://github.com/canton7/Stylet">
<Application.Resources>
<s:ApplicationLoader>
@@ -111,6 +112,10 @@
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}">
<Setter Property="Foreground" Value="{DynamicResource PrimaryTextBrush}" />
</Style>
<Style
x:Key="MaterialDesignFlatDarkButton"
BasedOn="{StaticResource MaterialDesignFlatButton}"
@@ -136,6 +141,89 @@
</Trigger>
</Style.Triggers>
</Style>
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
<DockPanel Background="{TemplateBinding Background}">
<ToggleButton
Name="HeaderSite"
BorderThickness="0"
Content="{TemplateBinding Header}"
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
Cursor="Hand"
DockPanel.Dock="Top"
Focusable="False"
Foreground="{TemplateBinding Foreground}"
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
IsTabStop="False"
Opacity=".87"
Style="{StaticResource MaterialDesignExpanderDownHeaderStyle}"
TextElement.FontSize="15" />
<Border
Name="ContentSite"
DockPanel.Dock="Bottom"
Visibility="Collapsed">
<StackPanel
Name="ContentPanel"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
<ContentPresenter
Name="PART_Content"
ContentStringFormat="{TemplateBinding ContentStringFormat}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
Focusable="False" />
</StackPanel>
</Border>
</DockPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="true">
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Right">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderRightHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Left">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderLeftHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Up">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderUpHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Down">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignExpanderDownHeaderStyle}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</s:ApplicationLoader>
</Application.Resources>
</Application>
@@ -0,0 +1,28 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DiscordChatExporter.Gui.Converters
{
[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))]
public class TimeSpanToDateTimeConverter : IValueConverter
{
public static TimeSpanToDateTimeConverter Instance { get; } = new TimeSpanToDateTimeConverter();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is TimeSpan timeSpanValue)
return DateTime.Today.Add(timeSpanValue);
return default(DateTime?);
}
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is DateTime dateTimeValue)
return dateTimeValue.TimeOfDay;
return default(TimeSpan?);
}
}
}
@@ -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" />
@@ -26,9 +26,23 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
public ExportFormat SelectedFormat { get; set; }
public DateTimeOffset? After { get; set; }
// This date/time abomination is required because we use separate controls to set these
public DateTimeOffset? Before { get; set; }
public DateTimeOffset? AfterDate { get; set; }
public bool IsAfterDateSet => AfterDate != null;
public TimeSpan? AfterTime { get; set; }
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
public DateTimeOffset? BeforeDate { get; set; }
public bool IsBeforeDateSet => BeforeDate != null;
public TimeSpan? BeforeTime { get; set; }
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
public int? PartitionLimit { get; set; }
@@ -60,12 +74,6 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs
_settingsService.LastPartitionLimit = PartitionLimit;
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
// Clamp 'after' and 'before' values
if (After > Before)
After = Before;
if (Before < After)
Before = After;
// If single channel - prompt file path
if (IsSingleChannel)
{
@@ -134,7 +134,7 @@ namespace DiscordChatExporter.Gui.ViewModels
try
{
var tokenValue = TokenValue?.Trim('"');
var tokenValue = TokenValue?.Trim('"', ' ');
if (string.IsNullOrWhiteSpace(tokenValue))
return;
@@ -150,11 +150,7 @@ namespace DiscordChatExporter.Gui.ViewModels
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
await foreach (var guild in discord.GetUserGuildsAsync())
{
var channels = await discord.GetGuildChannelsAsync(guild.Id);
guildChannelMap[guild] = channels
.OrderBy(c => c.Category)
.ThenBy(c => c.Name)
.ToArray();
guildChannelMap[guild] = await discord.GetGuildChannelsAsync(guild.Id);
}
GuildChannelMap = guildChannelMap;
@@ -82,30 +82,52 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8"
materialDesign:HintAssist.Hint="From (optional)"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="After (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent after this date will be exported" />
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="16,8"
materialDesign:HintAssist.Hint="To (optional)"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="Before (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding After, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding Before, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent before this date will be exported" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="0"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent after this time will be exported" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="1"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
ToolTip="If this is set, only messages sent before this time will be exported" />
</Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Messages per partition (optional)"
materialDesign:HintAssist.Hint="Messages per partition"
materialDesign:HintAssist.IsFloating="True"
Text="{Binding PartitionLimit, TargetNullValue=''}"
ToolTip="If this is set, the exported file will be split into multiple partitions, each containing no more than specified number of messages" />
+44 -8
View File
@@ -3,6 +3,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
@@ -23,6 +24,18 @@
<Window.TaskbarItemInfo>
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
</Window.TaskbarItemInfo>
<Window.Resources>
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Category" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Ascending" PropertyName="Category" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
</Window.Resources>
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<Grid>
<Grid.RowDefinitions>
@@ -253,12 +266,37 @@
<Border Grid.Column="1">
<ListBox
HorizontalContentAlignment="Stretch"
ItemsSource="{Binding AvailableChannels}"
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
SelectionMode="Extended"
TextSearch.TextPath="Model.Name">
TextSearch.TextPath="Model.Name"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<i:Interaction.Behaviors>
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</i:Interaction.Behaviors>
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
<Expander
Margin="0"
Padding="0"
Background="Transparent"
BorderBrush="{DynamicResource DividerBrush}"
BorderThickness="0,1,0,0"
Header="{Binding Name}"
IsExpanded="False">
<ItemsPresenter />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="-8" Background="Transparent">
@@ -278,16 +316,14 @@
VerticalAlignment="Center"
Kind="Pound" />
<!-- Channel category / name -->
<!-- Channel name -->
<TextBlock
Grid.Column="1"
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14">
<Run Foreground="{DynamicResource SecondaryTextBrush}" Text="{Binding Category, Mode=OneWay}" />
<Run Text="/" />
<Run Foreground="{DynamicResource PrimaryTextBrush}" Text="{Binding Name, Mode=OneWay}" />
</TextBlock>
FontSize="14"
Foreground="{DynamicResource PrimaryTextBrush}"
Text="{Binding Name, Mode=OneWay}" />
<!-- Is selected checkmark -->
<materialDesign:PackIcon
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<Version>2.21.1</Version>
<Version>2.23</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Alexey Golub</Copyright>
<LangVersion>latest</LangVersion>
+1
View File
@@ -4,6 +4,7 @@
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Donate](https://img.shields.io/badge/donate-$$$-purple.svg)](https://tyrrrz.me/donate)
[![Extra Services](https://img.shields.io/badge/extra%20services-xs:code-blue.svg)](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.