Rework HTML export

Fixes #8
pull/17/head
Alexey Golub 7 years ago
parent 5a57b4a6b1
commit a556926fe6

@ -48,9 +48,6 @@
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL">
<HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
</Reference>
<Reference Include="HtmlAgilityPack, Version=1.5.5.0, Culture=neutral, PublicKeyToken=bd319b19eaf3b43a, processorArchitecture=MSIL">
<HintPath>..\packages\HtmlAgilityPack.1.5.5\lib\Net45\HtmlAgilityPack.dll</HintPath>
</Reference>
<Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
</Reference>
@ -186,7 +183,6 @@
<ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportService\Template.html" />
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\packages\Ammy.1.2.87\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" />

@ -2,7 +2,7 @@
{
public enum AttachmentType
{
Unrecognized,
Other,
Image
}
}

@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
@ -11,11 +10,15 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<MessageGroup> MessageGroups { get; }
public ChannelChatLog(Guild guild, Channel channel, IEnumerable<MessageGroup> messageGroups)
public int TotalMessageCount { get; }
public ChannelChatLog(Guild guild, Channel channel, IReadOnlyList<MessageGroup> messageGroups,
int totalMessageCount)
{
Guild = guild;
Channel = channel;
MessageGroups = messageGroups.ToArray();
MessageGroups = messageGroups;
TotalMessageCount = totalMessageCount;
}
}
}

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
@ -20,14 +19,14 @@ namespace DiscordChatExporter.Models
public Message(string id, User author,
DateTime timeStamp, DateTime? editedTimeStamp,
string content, IEnumerable<Attachment> attachments)
string content, IReadOnlyList<Attachment> attachments)
{
Id = id;
Author = author;
TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp;
Content = content;
Attachments = attachments.ToArray();
Attachments = attachments;
}
public override string ToString()

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models
{
@ -12,11 +11,11 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<Message> Messages { get; }
public MessageGroup(User author, DateTime timeStamp, IEnumerable<Message> messages)
public MessageGroup(User author, DateTime timeStamp, IReadOnlyList<Message> messages)
{
Author = author;
TimeStamp = timeStamp;
Messages = messages.ToArray();
Messages = messages;
}
}
}

@ -1,18 +0,0 @@
<!-- This chat log was automatically generated by DiscordChatExporter (https://github.com/Tyrrrz/DiscordChatExporter) -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>Discord Chat Log</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style id="theme"></style>
</head>
<body>
<div id="info"></div>
<div id="log"></div>
</body>
</html>

@ -29,7 +29,7 @@ namespace DiscordChatExporter.Services
}
}
public async Task<IEnumerable<Guild>> GetGuildsAsync(string token)
public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
@ -38,12 +38,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url);
// Parse
var guilds = JArray.Parse(content).Select(ParseGuild);
var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
return guilds;
}
public async Task<IEnumerable<Channel>> GetDirectMessageChannelsAsync(string token)
public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{
// Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}";
@ -52,12 +52,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel);
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IEnumerable<Channel>> GetGuildChannelsAsync(string token, string guildId)
public async Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId)
{
// Form request url
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
@ -66,12 +66,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url);
// Parse
var channels = JArray.Parse(content).Select(ParseChannel);
var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels;
}
public async Task<IEnumerable<Message>> GetChannelMessagesAsync(string token, string channelId)
public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId)
{
var result = new List<Message>();
@ -92,7 +92,7 @@ namespace DiscordChatExporter.Services
var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list
string currentMessageId = null;
var currentMessageId = default(string);
foreach (var message in messages)
{
result.Add(message);
@ -192,7 +192,7 @@ namespace DiscordChatExporter.Services
var attachmentUrl = attachmentJson.Value<string>("url");
var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image
: AttachmentType.Unrecognized;
: AttachmentType.Other;
var attachmentFileName = attachmentJson.Value<string>("filename");
var attachmentFileSize = attachmentJson.Value<long>("size");

@ -1,11 +1,11 @@
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Resources;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Models;
using HtmlAgilityPack;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services
@ -19,80 +19,77 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService;
}
public Task ExportAsync(string filePath, ChannelChatLog log, Theme theme)
public async Task ExportAsync(string filePath, ChannelChatLog log, Theme theme)
{
return Task.Run(() =>
var themeCss = GetThemeCss(theme);
var dateFormat = _settingsService.DateFormat;
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128*1024))
{
var doc = GetTemplate();
var style = GetStyle(theme);
var dateFormat = _settingsService.DateFormat;
// Set theme
var themeHtml = doc.GetElementbyId("theme");
themeHtml.InnerHtml = style;
// Title
var titleHtml = doc.DocumentNode.Element("html").Element("head").Element("title");
titleHtml.InnerHtml = $"{log.Guild.Name} - {log.Channel.Name}";
// Info
var infoHtml = doc.GetElementbyId("info");
var infoLeftHtml = infoHtml.AppendChild(HtmlNode.CreateNode("<div class=\"info-left\"></div>"));
infoLeftHtml.AppendChild(HtmlNode.CreateNode(
$"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />"));
var infoRightHtml = infoHtml.AppendChild(HtmlNode.CreateNode("<div class=\"info-right\"></div>"));
infoRightHtml.AppendChild(HtmlNode.CreateNode(
$"<div class=\"guild-name\">{log.Guild.Name}</div>"));
infoRightHtml.AppendChild(HtmlNode.CreateNode(
$"<div class=\"channel-name\">{log.Channel.Name}</div>"));
infoRightHtml.AppendChild(HtmlNode.CreateNode(
$"<div class=\"misc\">{log.MessageGroups.SelectMany(g => g.Messages).Count():N0} messages</div>"));
// Log
var logHtml = doc.GetElementbyId("log");
foreach (var messageGroup in log.MessageGroups)
// Generation info
await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Html start
await writer.WriteLineAsync("<!DOCTYPE html>");
await writer.WriteLineAsync("<html lang=\"en\">");
// HEAD
await writer.WriteLineAsync("<head>");
await writer.WriteLineAsync($"<title>{log.Guild.Name} - {log.Channel.Name}</title>");
await writer.WriteLineAsync("<meta charset=\"utf-8\" />");
await writer.WriteLineAsync("<meta name=\"viewport\" content=\"width=device-width\" />");
await writer.WriteLineAsync($"<style>{themeCss}</style>");
await writer.WriteLineAsync("</head>");
// Body start
await writer.WriteLineAsync("<body>");
// Guild and channel info
await writer.WriteLineAsync("<div id=\"info\">");
await writer.WriteLineAsync("<div class=\"info-left\">");
await writer.WriteLineAsync($"<img class=\"guild-icon\" src=\"{log.Guild.IconUrl}\" />");
await writer.WriteLineAsync("</div>"); // info-left
await writer.WriteLineAsync("<div class=\"info-right\">");
await writer.WriteLineAsync($"<div class=\"guild-name\">{log.Guild.Name}</div>");
await writer.WriteLineAsync($"<div class=\"channel-name\">{log.Channel.Name}</div>");
await writer.WriteLineAsync($"<div class=\"misc\">{log.TotalMessageCount:N0} messages</div>");
await writer.WriteLineAsync("</div>"); // info-right
await writer.WriteLineAsync("</div>"); // info
// Chat log
await writer.WriteLineAsync("<div id=\"log\">");
foreach (var group in log.MessageGroups)
{
// Container
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>"));
// Left
var messageLeftHtml =
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-left\"></div>"));
// Avatar
messageLeftHtml.AppendChild(
HtmlNode.CreateNode($"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />"));
// Right
var messageRightHtml =
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-right\"></div>"));
// Author
var authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name);
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>"));
// Date
var timeStamp = HtmlDocument.HtmlEncode(messageGroup.TimeStamp.ToString(dateFormat));
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>"));
// Individual messages
foreach (var message in messageGroup.Messages)
await writer.WriteLineAsync("<div class=\"msg\">");
await writer.WriteLineAsync("<div class=\"msg-left\">");
await writer.WriteLineAsync($"<img class=\"msg-avatar\" src=\"{group.Author.AvatarUrl}\" />");
await writer.WriteLineAsync("</div>");
await writer.WriteLineAsync("<div class=\"msg-right\">");
await writer.WriteLineAsync($"<span class=\"msg-user\">{HtmlEncode(group.Author.Name)}</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(dateFormat));
await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Message
foreach (var message in group.Messages)
{
// Content
if (message.Content.IsNotBlank())
{
var content = FormatMessageContent(message.Content);
var contentHtml =
messageRightHtml.AppendChild(
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
await writer.WriteLineAsync("<div class=\"msg-content\">");
var contentFormatted = FormatMessageContent(message.Content);
await writer.WriteAsync(contentFormatted);
// Edited timestamp
if (message.EditedTimeStamp != null)
{
contentHtml.AppendChild(
HtmlNode.CreateNode(
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp.Value.ToString(dateFormat)}\">(edited)</span>"));
var editedTimeStampFormatted =
HtmlEncode(message.EditedTimeStamp.Value.ToString(dateFormat));
await writer.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
}
await writer.WriteLineAsync("</div>"); // msg-content
}
// Attachments
@ -100,51 +97,37 @@ namespace DiscordChatExporter.Services
{
if (attachment.Type == AttachmentType.Image)
{
messageRightHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" +
"</a>" +
"</div>"));
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
await writer.WriteLineAsync($"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
await writer.WriteLineAsync("</a>");
await writer.WriteLineAsync("</div>");
}
else
{
messageRightHtml.AppendChild(
HtmlNode.CreateNode("<div class=\"msg-attachment\">" +
$"<a href=\"{attachment.Url}\">" +
$"Attachment: {attachment.FileName} ({NormalizeFileSize(attachment.FileSize)})" +
"</a>" +
"</div>"));
await writer.WriteLineAsync("<div class=\"msg-attachment\">");
await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
var fileSizeFormatted = FormatFileSize(attachment.FileSize);
await writer.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
await writer.WriteLineAsync("</a>");
await writer.WriteLineAsync("</div>");
}
}
}
await writer.WriteLineAsync("</div>"); // msg-right
await writer.WriteLineAsync("</div>"); // msg
}
await writer.WriteLineAsync("</div>"); // log
doc.Save(filePath);
});
await writer.WriteLineAsync("</body>");
await writer.WriteLineAsync("</html>");
}
}
}
public partial class ExportService
{
private static HtmlDocument GetTemplate()
{
var resourcePath = "DiscordChatExporter.Resources.ExportService.Template.html";
var assembly = Assembly.GetExecutingAssembly();
var stream = assembly.GetManifestResourceStream(resourcePath);
if (stream == null)
throw new MissingManifestResourceException("Could not find template resource");
using (stream)
{
var doc = new HtmlDocument();
doc.Load(stream);
return doc;
}
}
private static string GetStyle(Theme theme)
private static string GetThemeCss(Theme theme)
{
var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css";
@ -160,7 +143,12 @@ namespace DiscordChatExporter.Services
}
}
private static string NormalizeFileSize(long fileSize)
private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string FormatFileSize(long fileSize)
{
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize;
@ -178,7 +166,7 @@ namespace DiscordChatExporter.Services
private static string FormatMessageContent(string content)
{
// Encode HTML
content = HtmlDocument.HtmlEncode(content);
content = HtmlEncode(content);
// Links from URLs
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",

@ -6,12 +6,12 @@ namespace DiscordChatExporter.Services
{
public interface IDataService
{
Task<IEnumerable<Guild>> GetGuildsAsync(string token);
Task<IReadOnlyList<Guild>> GetGuildsAsync(string token);
Task<IEnumerable<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token);
Task<IEnumerable<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IReadOnlyList<Channel>> GetGuildChannelsAsync(string token, string guildId);
Task<IEnumerable<Message>> GetChannelMessagesAsync(string token, string channelId);
Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId);
}
}

@ -5,6 +5,6 @@ namespace DiscordChatExporter.Services
{
public interface IMessageGroupService
{
IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages);
IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
}
}

@ -13,7 +13,7 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService;
}
public IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages)
public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
{
var groupLimit = _settingsService.MessageGroupLimit;
var result = new List<MessageGroup>();
@ -37,7 +37,7 @@ namespace DiscordChatExporter.Services
// If condition is true - flush buffer
if (breakCondition)
{
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
result.Add(group);
groupBuffer.Clear();
}
@ -50,7 +50,7 @@ namespace DiscordChatExporter.Services
if (groupBuffer.Any())
{
var groupFirst = groupBuffer.First();
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer);
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
result.Add(group);
}

@ -127,8 +127,7 @@ namespace DiscordChatExporter.ViewModels
foreach (var guild in guilds)
{
var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id);
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat);
_guildChannelsMap[guild] = channels.ToArray();
_guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
}
}
}
@ -175,7 +174,7 @@ namespace DiscordChatExporter.ViewModels
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups);
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
// Export
await _exportService.ExportAsync(sfd.FileName, chatLog, _settingsService.Theme);

@ -3,7 +3,6 @@
<package id="Ammy" version="1.2.87" targetFramework="net461" />
<package id="Ammy.WPF" version="1.2.87" targetFramework="net461" />
<package id="CommonServiceLocator" version="1.3" targetFramework="net461" />
<package id="HtmlAgilityPack" version="1.5.5" targetFramework="net461" />
<package id="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" />

Loading…
Cancel
Save