Compare commits

...

13 Commits

Author SHA1 Message Date
Alexey Golub ca452d2421 Update readme 2017-10-01 01:56:42 +03:00
Alexey Golub 9e7d4033e0 Update version 2017-10-01 01:55:04 +03:00
Alexey Golub 263a606bff Improve error messages 2017-10-01 01:49:39 +03:00
Alexey Golub 1e853f11b0 Fix styling for pre 2017-10-01 01:49:26 +03:00
Alexey Golub 77d0956f5d Redesign file picker in ExportSetupDialog 2017-10-01 00:32:10 +03:00
Alexey Golub d251e8ecf6 Update readme 2017-09-30 23:14:32 +03:00
Alexey Golub 01f0e6952d Add from/to date pickers
Fixes #6
2017-09-30 22:57:52 +03:00
Alexey Golub 28a67ab60d Add export dialog and implement export as plain text 2017-09-30 22:22:28 +03:00
Alexey Golub 42bb4e72d2 Remove HtmlAgilityPack from readme 2017-09-30 17:27:09 +03:00
Alexey Golub a79d72f975 Update version 2017-09-30 17:25:20 +03:00
Alexey Golub b110413c08 Fix div.pre output styling 2017-09-30 17:24:46 +03:00
Alexey Golub a556926fe6 Rework HTML export
Fixes #8
2017-09-30 17:18:41 +03:00
Alexey Golub 5a57b4a6b1 Generalize HTTP exceptions 2017-09-29 20:24:02 +03:00
39 changed files with 649 additions and 267 deletions
+4 -2
View File
@@ -19,9 +19,10 @@ namespace DiscordChatExporter
// View models // View models
SimpleIoc.Default.Register<IErrorViewModel, ErrorViewModel>(true); SimpleIoc.Default.Register<IErrorViewModel, ErrorViewModel>(true);
SimpleIoc.Default.Register<IExportDoneViewModel, ExportDoneViewModel>(true);
SimpleIoc.Default.Register<IExportSetupViewModel, ExportSetupViewModel>(true);
SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true); SimpleIoc.Default.Register<IMainViewModel, MainViewModel>(true);
SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true); SimpleIoc.Default.Register<ISettingsViewModel, SettingsViewModel>(true);
SimpleIoc.Default.Register<IExportDoneViewModel, ExportDoneViewModel>(true);
// Load settings // Load settings
ServiceLocator.Current.GetInstance<ISettingsService>().Load(); ServiceLocator.Current.GetInstance<ISettingsService>().Load();
@@ -34,8 +35,9 @@ namespace DiscordChatExporter
} }
public IErrorViewModel ErrorViewModel => ServiceLocator.Current.GetInstance<IErrorViewModel>(); public IErrorViewModel ErrorViewModel => ServiceLocator.Current.GetInstance<IErrorViewModel>();
public IExportDoneViewModel ExportDoneViewModel => ServiceLocator.Current.GetInstance<IExportDoneViewModel>();
public IExportSetupViewModel ExportSetupViewModel => ServiceLocator.Current.GetInstance<IExportSetupViewModel>();
public IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance<IMainViewModel>(); public IMainViewModel MainViewModel => ServiceLocator.Current.GetInstance<IMainViewModel>();
public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>(); public ISettingsViewModel SettingsViewModel => ServiceLocator.Current.GetInstance<ISettingsViewModel>();
public IExportDoneViewModel ExportDoneViewModel => ServiceLocator.Current.GetInstance<IExportDoneViewModel>();
} }
} }
+16 -5
View File
@@ -48,9 +48,6 @@
<Reference Include="GalaSoft.MvvmLight.Platform, Version=5.3.0.19032, Culture=neutral, PublicKeyToken=5f873c45e98af8a1, processorArchitecture=MSIL"> <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> <HintPath>..\packages\MvvmLightLibs.5.3.0.0\lib\net45\GalaSoft.MvvmLight.Platform.dll</HintPath>
</Reference> </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"> <Reference Include="MaterialDesignColors, Version=1.1.3.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath> <HintPath>..\packages\MaterialDesignColors.1.1.3\lib\net45\MaterialDesignColors.dll</HintPath>
</Reference> </Reference>
@@ -84,17 +81,23 @@
<Reference Include="PresentationFramework" /> <Reference Include="PresentationFramework" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Compile Include="Exceptions\UnathorizedException.cs" /> <Compile Include="Exceptions\HttpErrorStatusCodeException.cs" />
<Compile Include="Messages\ShowErrorMessage.cs" /> <Compile Include="Messages\ShowErrorMessage.cs" />
<Compile Include="Messages\ShowExportDoneMessage.cs" /> <Compile Include="Messages\ShowExportDoneMessage.cs" />
<Compile Include="Messages\ShowExportSetupMessage.cs" />
<Compile Include="Messages\ShowSettingsMessage.cs" /> <Compile Include="Messages\ShowSettingsMessage.cs" />
<Compile Include="Messages\StartExportMessage.cs" />
<Compile Include="Models\AttachmentType.cs" /> <Compile Include="Models\AttachmentType.cs" />
<Compile Include="Models\ChannelChatLog.cs" /> <Compile Include="Models\ChannelChatLog.cs" />
<Compile Include="Models\ChannelType.cs" /> <Compile Include="Models\ChannelType.cs" />
<Compile Include="Models\ExportFormat.cs" />
<Compile Include="Models\Extensions.cs" />
<Compile Include="Services\IMessageGroupService.cs" /> <Compile Include="Services\IMessageGroupService.cs" />
<Compile Include="Services\MessageGroupService.cs" /> <Compile Include="Services\MessageGroupService.cs" />
<Compile Include="ViewModels\ErrorViewModel.cs" /> <Compile Include="ViewModels\ErrorViewModel.cs" />
<Compile Include="ViewModels\ExportSetupViewModel.cs" />
<Compile Include="ViewModels\IErrorViewModel.cs" /> <Compile Include="ViewModels\IErrorViewModel.cs" />
<Compile Include="ViewModels\IExportSetupViewModel.cs" />
<Compile Include="ViewModels\ISettingsViewModel.cs" /> <Compile Include="ViewModels\ISettingsViewModel.cs" />
<Compile Include="ViewModels\IExportDoneViewModel.cs" /> <Compile Include="ViewModels\IExportDoneViewModel.cs" />
<Compile Include="ViewModels\SettingsViewModel.cs" /> <Compile Include="ViewModels\SettingsViewModel.cs" />
@@ -105,6 +108,9 @@
<Compile Include="Views\ExportDoneDialog.ammy.cs"> <Compile Include="Views\ExportDoneDialog.ammy.cs">
<DependentUpon>ExportDoneDialog.ammy</DependentUpon> <DependentUpon>ExportDoneDialog.ammy</DependentUpon>
</Compile> </Compile>
<Compile Include="Views\ExportSetupDialog.ammy.cs">
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
</Compile>
<Compile Include="Views\SettingsDialog.ammy.cs"> <Compile Include="Views\SettingsDialog.ammy.cs">
<DependentUpon>SettingsDialog.ammy</DependentUpon> <DependentUpon>SettingsDialog.ammy</DependentUpon>
</Compile> </Compile>
@@ -123,6 +129,11 @@
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
<DependentUpon>ExportDoneDialog.ammy</DependentUpon> <DependentUpon>ExportDoneDialog.ammy</DependentUpon>
</Page> </Page>
<Page Include="Views\ExportSetupDialog.g.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
<DependentUpon>ExportSetupDialog.ammy</DependentUpon>
</Page>
<Page Include="Views\MainWindow.g.xaml"> <Page Include="Views\MainWindow.g.xaml">
<SubType>Designer</SubType> <SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
@@ -177,6 +188,7 @@
</None> </None>
<None Include="Views\ErrorDialog.ammy" /> <None Include="Views\ErrorDialog.ammy" />
<None Include="Views\ExportDoneDialog.ammy" /> <None Include="Views\ExportDoneDialog.ammy" />
<None Include="Views\ExportSetupDialog.ammy" />
<None Include="Views\MainWindow.ammy" /> <None Include="Views\MainWindow.ammy" />
<None Include="Views\SettingsDialog.ammy" /> <None Include="Views\SettingsDialog.ammy" />
</ItemGroup> </ItemGroup>
@@ -186,7 +198,6 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="Resources\ExportService\DarkTheme.css" /> <EmbeddedResource Include="Resources\ExportService\DarkTheme.css" />
<EmbeddedResource Include="Resources\ExportService\LightTheme.css" /> <EmbeddedResource Include="Resources\ExportService\LightTheme.css" />
<EmbeddedResource Include="Resources\ExportService\Template.html" />
</ItemGroup> </ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <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')" /> <Import Project="..\packages\Ammy.1.2.87\build\Ammy.targets" Condition="Exists('..\packages\Ammy.1.2.87\build\Ammy.targets')" />
@@ -0,0 +1,15 @@
using System;
using System.Net;
namespace DiscordChatExporter.Exceptions
{
public class HttpErrorStatusCodeException : Exception
{
public HttpStatusCode StatusCode { get; }
public HttpErrorStatusCodeException(HttpStatusCode statusCode)
{
StatusCode = statusCode;
}
}
}
@@ -1,8 +0,0 @@
using System;
namespace DiscordChatExporter.Exceptions
{
public class UnathorizedException : Exception
{
}
}
@@ -0,0 +1,17 @@
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Messages
{
public class ShowExportSetupMessage
{
public Guild Guild { get; }
public Channel Channel { get; }
public ShowExportSetupMessage(Guild guild, Channel channel)
{
Guild = guild;
Channel = channel;
}
}
}
@@ -0,0 +1,28 @@
using System;
using DiscordChatExporter.Models;
namespace DiscordChatExporter.Messages
{
public class StartExportMessage
{
public Channel Channel { get; }
public string FilePath { get; }
public ExportFormat Format { get; }
public DateTime? From { get; }
public DateTime? To { get; }
public StartExportMessage(Channel channel, string filePath, ExportFormat format,
DateTime? from, DateTime? to)
{
Channel = channel;
FilePath = filePath;
Format = format;
From = from;
To = to;
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
{ {
public enum AttachmentType public enum AttachmentType
{ {
Unrecognized, Other,
Image Image
} }
} }
+6 -3
View File
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models namespace DiscordChatExporter.Models
{ {
@@ -11,11 +10,15 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<MessageGroup> MessageGroups { get; } 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; Guild = guild;
Channel = channel; Channel = channel;
MessageGroups = messageGroups.ToArray(); MessageGroups = messageGroups;
TotalMessageCount = totalMessageCount;
} }
} }
} }
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Models
{
public enum ExportFormat
{
Text,
Html
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace DiscordChatExporter.Models
{
public static class Extensions
{
public static string GetFileExtension(this ExportFormat format)
{
if (format == ExportFormat.Text)
return "txt";
if (format == ExportFormat.Html)
return "html";
return null;
}
}
}
+2 -3
View File
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models namespace DiscordChatExporter.Models
{ {
@@ -20,14 +19,14 @@ namespace DiscordChatExporter.Models
public Message(string id, User author, public Message(string id, User author,
DateTime timeStamp, DateTime? editedTimeStamp, DateTime timeStamp, DateTime? editedTimeStamp,
string content, IEnumerable<Attachment> attachments) string content, IReadOnlyList<Attachment> attachments)
{ {
Id = id; Id = id;
Author = author; Author = author;
TimeStamp = timeStamp; TimeStamp = timeStamp;
EditedTimeStamp = editedTimeStamp; EditedTimeStamp = editedTimeStamp;
Content = content; Content = content;
Attachments = attachments.ToArray(); Attachments = attachments;
} }
public override string ToString() public override string ToString()
+2 -3
View File
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Models namespace DiscordChatExporter.Models
{ {
@@ -12,11 +11,11 @@ namespace DiscordChatExporter.Models
public IReadOnlyList<Message> Messages { get; } 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; Author = author;
TimeStamp = timeStamp; TimeStamp = timeStamp;
Messages = messages.ToArray(); Messages = messages;
} }
} }
} }
@@ -3,5 +3,5 @@
[assembly: AssemblyTitle("DiscordChatExporter")] [assembly: AssemblyTitle("DiscordChatExporter")]
[assembly: AssemblyCompany("Tyrrrz")] [assembly: AssemblyCompany("Tyrrrz")]
[assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")] [assembly: AssemblyCopyright("Copyright (c) 2017 Alexey Golub")]
[assembly: AssemblyVersion("2.0.0.0")] [assembly: AssemblyVersion("2.2")]
[assembly: AssemblyFileVersion("2.0.0.0")] [assembly: AssemblyFileVersion("2.2")]
@@ -12,11 +12,21 @@ a {
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
div.pre, span.pre { div.pre {
background-color: #2F3136;
color: rgb(131, 148, 150);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
white-space: pre;
}
span.pre {
background-color: #2F3136; background-color: #2F3136;
font-family: Consolas, Courier New, Courier, Monospace; font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px; padding-left: 2px;
padding-right: 2px; padding-right: 2px;
white-space: pre;
} }
div#info { div#info {
@@ -12,7 +12,15 @@ a {
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }
div.pre, span.pre { div.pre {
background-color: #F9F9F9;
color: rgb(101, 123, 131);
font-family: Consolas, Courier New, Courier, Monospace;
margin-top: 4px;
padding: 8px;
}
span.pre {
background-color: #F9F9F9; background-color: #F9F9F9;
font-family: Consolas, Courier New, Courier, Monospace; font-family: Consolas, Courier New, Courier, Monospace;
padding-left: 2px; padding-left: 2px;
@@ -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>
+21 -13
View File
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Exceptions; using DiscordChatExporter.Exceptions;
@@ -21,16 +20,16 @@ namespace DiscordChatExporter.Services
using (var response = await _httpClient.GetAsync(url)) using (var response = await _httpClient.GetAsync(url))
{ {
// Check status code // Check status code
if (response.StatusCode.IsEither(HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden)) // We throw our own exception here because default one doesn't have status code
throw new UnathorizedException(); if (!response.IsSuccessStatusCode)
response.EnsureSuccessStatusCode(); throw new HttpErrorStatusCodeException(response.StatusCode);
// Get content // Get content
return await response.Content.ReadAsStringAsync(); return await response.Content.ReadAsStringAsync();
} }
} }
public async Task<IEnumerable<Guild>> GetGuildsAsync(string token) public async Task<IReadOnlyList<Guild>> GetGuildsAsync(string token)
{ {
// Form request url // Form request url
var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100"; var url = $"{ApiRoot}/users/@me/guilds?token={token}&limit=100";
@@ -39,12 +38,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url); var content = await GetStringAsync(url);
// Parse // Parse
var guilds = JArray.Parse(content).Select(ParseGuild); var guilds = JArray.Parse(content).Select(ParseGuild).ToArray();
return guilds; return guilds;
} }
public async Task<IEnumerable<Channel>> GetDirectMessageChannelsAsync(string token) public async Task<IReadOnlyList<Channel>> GetDirectMessageChannelsAsync(string token)
{ {
// Form request url // Form request url
var url = $"{ApiRoot}/users/@me/channels?token={token}"; var url = $"{ApiRoot}/users/@me/channels?token={token}";
@@ -53,12 +52,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url); var content = await GetStringAsync(url);
// Parse // Parse
var channels = JArray.Parse(content).Select(ParseChannel); var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels; 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 // Form request url
var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}"; var url = $"{ApiRoot}/guilds/{guildId}/channels?token={token}";
@@ -67,12 +66,12 @@ namespace DiscordChatExporter.Services
var content = await GetStringAsync(url); var content = await GetStringAsync(url);
// Parse // Parse
var channels = JArray.Parse(content).Select(ParseChannel); var channels = JArray.Parse(content).Select(ParseChannel).ToArray();
return channels; return channels;
} }
public async Task<IEnumerable<Message>> GetChannelMessagesAsync(string token, string channelId) public async Task<IReadOnlyList<Message>> GetChannelMessagesAsync(string token, string channelId, DateTime? from, DateTime? to)
{ {
var result = new List<Message>(); var result = new List<Message>();
@@ -93,7 +92,7 @@ namespace DiscordChatExporter.Services
var messages = JArray.Parse(content).Select(ParseMessage); var messages = JArray.Parse(content).Select(ParseMessage);
// Add messages to list // Add messages to list
string currentMessageId = null; var currentMessageId = default(string);
foreach (var message in messages) foreach (var message in messages)
{ {
result.Add(message); result.Add(message);
@@ -103,6 +102,9 @@ namespace DiscordChatExporter.Services
// If no messages - break // If no messages - break
if (currentMessageId == null) break; if (currentMessageId == null) break;
// If last message is older than from date - break
if (from != null && result.Last().TimeStamp < from) break;
// Otherwise offset the next request // Otherwise offset the next request
beforeId = currentMessageId; beforeId = currentMessageId;
} }
@@ -110,6 +112,12 @@ namespace DiscordChatExporter.Services
// Messages appear newest first, we need to reverse // Messages appear newest first, we need to reverse
result.Reverse(); result.Reverse();
// Filter
if (from != null)
result.RemoveAll(m => m.TimeStamp < from);
if (to != null)
result.RemoveAll(m => m.TimeStamp > to);
return result; return result;
} }
@@ -193,7 +201,7 @@ namespace DiscordChatExporter.Services
var attachmentUrl = attachmentJson.Value<string>("url"); var attachmentUrl = attachmentJson.Value<string>("url");
var attachmentType = attachmentJson["width"] != null var attachmentType = attachmentJson["width"] != null
? AttachmentType.Image ? AttachmentType.Image
: AttachmentType.Unrecognized; : AttachmentType.Other;
var attachmentFileName = attachmentJson.Value<string>("filename"); var attachmentFileName = attachmentJson.Value<string>("filename");
var attachmentFileSize = attachmentJson.Value<long>("size"); var attachmentFileSize = attachmentJson.Value<long>("size");
+140 -102
View File
@@ -1,11 +1,12 @@
using System.IO; using System;
using System.Linq; using System.IO;
using System.Net;
using System.Reflection; using System.Reflection;
using System.Resources; using System.Resources;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Models; using DiscordChatExporter.Models;
using HtmlAgilityPack;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Services namespace DiscordChatExporter.Services
@@ -19,80 +20,120 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public Task ExportAsync(string filePath, ChannelChatLog log, Theme theme) public async Task ExportAsTextAsync(string filePath, ChannelChatLog log)
{ {
return Task.Run(() => var dateFormat = _settingsService.DateFormat;
using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
{ {
var doc = GetTemplate(); // Guild and channel info
var style = GetStyle(theme); await writer.WriteLineAsync("=".Repeat(16));
var dateFormat = _settingsService.DateFormat; await writer.WriteLineAsync($"Guild: {log.Guild}");
await writer.WriteLineAsync($"Channel: {log.Channel}");
await writer.WriteLineAsync($"Messages: {log.TotalMessageCount:N0}");
await writer.WriteLineAsync("=".Repeat(16));
await writer.WriteLineAsync();
// Set theme // Chat log
var themeHtml = doc.GetElementbyId("theme"); foreach (var group in log.MessageGroups)
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)
{ {
// Container var timeStampFormatted = group.TimeStamp.ToString(dateFormat);
var messageHtml = logHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg\"></div>")); await writer.WriteLineAsync($"{group.Author} [{timeStampFormatted}]");
// Left // Messages
var messageLeftHtml = foreach (var message in group.Messages)
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-left\"></div>")); {
if (message.Content.IsNotBlank())
{
var contentFormatted = message.Content.Replace("\n", Environment.NewLine);
await writer.WriteLineAsync(contentFormatted);
}
// Avatar // Attachments
messageLeftHtml.AppendChild( foreach (var attachment in message.Attachments)
HtmlNode.CreateNode($"<img class=\"msg-avatar\" src=\"{messageGroup.Author.AvatarUrl}\" />")); {
await writer.WriteLineAsync(attachment.Url);
}
}
// Right await writer.WriteLineAsync();
var messageRightHtml = }
messageHtml.AppendChild(HtmlNode.CreateNode("<div class=\"msg-right\"></div>")); }
}
// Author public async Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, Theme theme)
var authorName = HtmlDocument.HtmlEncode(messageGroup.Author.Name); {
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-user\">{authorName}</span>")); var themeCss = GetThemeCss(theme);
var dateFormat = _settingsService.DateFormat;
// Date using (var writer = new StreamWriter(filePath, false, Encoding.UTF8, 128 * 1024))
var timeStamp = HtmlDocument.HtmlEncode(messageGroup.TimeStamp.ToString(dateFormat)); {
messageRightHtml.AppendChild(HtmlNode.CreateNode($"<span class=\"msg-date\">{timeStamp}</span>")); // Generation info
await writer.WriteLineAsync("<!-- https://github.com/Tyrrrz/DiscordChatExporter -->");
// Individual messages // Html start
foreach (var message in messageGroup.Messages) await writer.WriteLineAsync("<!DOCTYPE html>");
await writer.WriteLineAsync("<html lang=\"en\">");
// HEAD
await writer.WriteLineAsync("<head>");
await writer.WriteLineAsync($"<title>{log.Guild} - {log.Channel}</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}</div>");
await writer.WriteLineAsync($"<div class=\"channel-name\">{log.Channel}</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)
{
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.WriteAsync($"<span class=\"msg-user\" title=\"{HtmlEncode(group.Author)}\">");
await writer.WriteAsync(HtmlEncode(group.Author.Name));
await writer.WriteLineAsync("</span>");
var timeStampFormatted = HtmlEncode(group.TimeStamp.ToString(dateFormat));
await writer.WriteLineAsync($"<span class=\"msg-date\">{timeStampFormatted}</span>");
// Messages
foreach (var message in group.Messages)
{ {
// Content // Content
if (message.Content.IsNotBlank()) if (message.Content.IsNotBlank())
{ {
var content = FormatMessageContent(message.Content); await writer.WriteLineAsync("<div class=\"msg-content\">");
var contentHtml = var contentFormatted = FormatMessageContentHtml(message.Content);
messageRightHtml.AppendChild( await writer.WriteAsync(contentFormatted);
HtmlNode.CreateNode($"<div class=\"msg-content\">{content}</div>"));
// Edited timestamp // Edited timestamp
if (message.EditedTimeStamp != null) if (message.EditedTimeStamp != null)
{ {
contentHtml.AppendChild( var editedTimeStampFormatted =
HtmlNode.CreateNode( HtmlEncode(message.EditedTimeStamp.Value.ToString(dateFormat));
$"<span class=\"msg-edited\" title=\"{message.EditedTimeStamp.Value.ToString(dateFormat)}\">(edited)</span>")); await writer.WriteAsync(
$"<span class=\"msg-edited\" title=\"{editedTimeStampFormatted}\">(edited)</span>");
} }
await writer.WriteLineAsync("</div>"); // msg-content
} }
// Attachments // Attachments
@@ -100,51 +141,38 @@ namespace DiscordChatExporter.Services
{ {
if (attachment.Type == AttachmentType.Image) if (attachment.Type == AttachmentType.Image)
{ {
messageRightHtml.AppendChild( await writer.WriteLineAsync("<div class=\"msg-attachment\">");
HtmlNode.CreateNode("<div class=\"msg-attachment\">" + await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
$"<a href=\"{attachment.Url}\">" + await writer.WriteLineAsync(
$"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />" + $"<img class=\"msg-attachment\" src=\"{attachment.Url}\" />");
"</a>" + await writer.WriteLineAsync("</a>");
"</div>")); await writer.WriteLineAsync("</div>");
} }
else else
{ {
messageRightHtml.AppendChild( await writer.WriteLineAsync("<div class=\"msg-attachment\">");
HtmlNode.CreateNode("<div class=\"msg-attachment\">" + await writer.WriteLineAsync($"<a href=\"{attachment.Url}\">");
$"<a href=\"{attachment.Url}\">" + var fileSizeFormatted = FormatFileSize(attachment.FileSize);
$"Attachment: {attachment.FileName} ({NormalizeFileSize(attachment.FileSize)})" + await writer.WriteLineAsync($"Attachment: {attachment.FileName} ({fileSizeFormatted})");
"</a>" + await writer.WriteLineAsync("</a>");
"</div>")); 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 public partial class ExportService
{ {
private static HtmlDocument GetTemplate() private static string GetThemeCss(Theme theme)
{
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)
{ {
var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css"; var resourcePath = $"DiscordChatExporter.Resources.ExportService.{theme}Theme.css";
@@ -160,7 +188,17 @@ namespace DiscordChatExporter.Services
} }
} }
private static string NormalizeFileSize(long fileSize) private static string HtmlEncode(string str)
{
return WebUtility.HtmlEncode(str);
}
private static string HtmlEncode(object obj)
{
return WebUtility.HtmlEncode(obj.ToString());
}
private static string FormatFileSize(long fileSize)
{ {
string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}; string[] units = {"B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"};
double size = fileSize; double size = fileSize;
@@ -175,23 +213,23 @@ namespace DiscordChatExporter.Services
return $"{size:0.#} {units[unit]}"; return $"{size:0.#} {units[unit]}";
} }
private static string FormatMessageContent(string content) private static string FormatMessageContentHtml(string content)
{ {
// Encode HTML // Encode HTML
content = HtmlDocument.HtmlEncode(content); content = HtmlEncode(content);
// Preformatted div
content = Regex.Replace(content, "```+(?:[^`]*?\\n)?([^`]+)\\n?```+",
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
// Preformatted span
content = Regex.Replace(content, "`([^`]+)`",
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
// Links from URLs // Links from URLs
content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)", content = Regex.Replace(content, "((https?|ftp)://[^\\s/$.?#].[^\\s]*)",
"<a href=\"$1\">$1</a>"); "<a href=\"$1\">$1</a>");
// Preformatted multiline
content = Regex.Replace(content, "```([^`]*?)```",
m => "<div class=\"pre\">" + m.Groups[1].Value + "</div>");
// Preformatted inline
content = Regex.Replace(content, "`([^`]*?)`",
m => "<span class=\"pre\">" + m.Groups[1].Value + "</span>");
// Bold // Bold
content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>"); content = Regex.Replace(content, "\\*\\*([^\\*]*?)\\*\\*", "<b>$1</b>");
@@ -205,7 +243,7 @@ namespace DiscordChatExporter.Services
content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>"); content = Regex.Replace(content, "~~([^~]*?)~~", "<s>$1</s>");
// New lines // New lines
content = content.Replace("\n", "</br>"); content = content.Replace("\n", "<br />");
return content; return content;
} }
+7 -5
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Models; using DiscordChatExporter.Models;
@@ -6,12 +7,13 @@ namespace DiscordChatExporter.Services
{ {
public interface IDataService 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,
DateTime? from, DateTime? to);
} }
} }
@@ -5,6 +5,7 @@ namespace DiscordChatExporter.Services
{ {
public interface IExportService public interface IExportService
{ {
Task ExportAsync(string filePath, ChannelChatLog log, Theme theme); Task ExportAsTextAsync(string filePath, ChannelChatLog log);
Task ExportAsHtmlAsync(string filePath, ChannelChatLog log, Theme theme);
} }
} }
@@ -5,6 +5,6 @@ namespace DiscordChatExporter.Services
{ {
public interface IMessageGroupService public interface IMessageGroupService
{ {
IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages); IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages);
} }
} }
@@ -4,11 +4,13 @@ namespace DiscordChatExporter.Services
{ {
public interface ISettingsService public interface ISettingsService
{ {
string Token { get; set; }
Theme Theme { get; set; } Theme Theme { get; set; }
string DateFormat { get; set; } string DateFormat { get; set; }
int MessageGroupLimit { get; set; } int MessageGroupLimit { get; set; }
string LastToken { get; set; }
ExportFormat LastExportFormat { get; set; }
void Load(); void Load();
void Save(); void Save();
} }
@@ -13,7 +13,7 @@ namespace DiscordChatExporter.Services
_settingsService = settingsService; _settingsService = settingsService;
} }
public IEnumerable<MessageGroup> GroupMessages(IEnumerable<Message> messages) public IReadOnlyList<MessageGroup> GroupMessages(IReadOnlyList<Message> messages)
{ {
var groupLimit = _settingsService.MessageGroupLimit; var groupLimit = _settingsService.MessageGroupLimit;
var result = new List<MessageGroup>(); var result = new List<MessageGroup>();
@@ -37,7 +37,7 @@ namespace DiscordChatExporter.Services
// If condition is true - flush buffer // If condition is true - flush buffer
if (breakCondition) if (breakCondition)
{ {
var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer); var group = new MessageGroup(groupFirst.Author, groupFirst.TimeStamp, groupBuffer.ToArray());
result.Add(group); result.Add(group);
groupBuffer.Clear(); groupBuffer.Clear();
} }
@@ -50,7 +50,7 @@ namespace DiscordChatExporter.Services
if (groupBuffer.Any()) if (groupBuffer.Any())
{ {
var groupFirst = groupBuffer.First(); 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); result.Add(group);
} }
@@ -5,11 +5,13 @@ namespace DiscordChatExporter.Services
{ {
public class SettingsService : SettingsManager, ISettingsService public class SettingsService : SettingsManager, ISettingsService
{ {
public string Token { get; set; }
public Theme Theme { get; set; } public Theme Theme { get; set; }
public string DateFormat { get; set; } = "dd-MMM-yy hh:mm"; public string DateFormat { get; set; } = "dd-MMM-yy hh:mm";
public int MessageGroupLimit { get; set; } = 20; public int MessageGroupLimit { get; set; } = 20;
public string LastToken { get; set; }
public ExportFormat LastExportFormat { get; set; } = ExportFormat.Html;
public SettingsService() public SettingsService()
{ {
Configuration.StorageSpace = StorageSpace.Instance; Configuration.StorageSpace = StorageSpace.Instance;
@@ -9,7 +9,11 @@ namespace DiscordChatExporter.ViewModels
public ErrorViewModel() public ErrorViewModel()
{ {
MessengerInstance.Register<ShowErrorMessage>(this, m => Message = m.Message); // Messages
MessengerInstance.Register<ShowErrorMessage>(this, m =>
{
Message = m.Message;
});
} }
} }
} }
@@ -14,10 +14,14 @@ namespace DiscordChatExporter.ViewModels
public ExportDoneViewModel() public ExportDoneViewModel()
{ {
MessengerInstance.Register<ShowExportDoneMessage>(this, m => _filePath = m.FilePath);
// Commands // Commands
OpenCommand = new RelayCommand(Open); OpenCommand = new RelayCommand(Open);
// Messages
MessengerInstance.Register<ShowExportDoneMessage>(this, m =>
{
_filePath = m.FilePath;
});
} }
private void Open() private void Open()
@@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DiscordChatExporter.Messages;
using DiscordChatExporter.Models;
using DiscordChatExporter.Services;
using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.ViewModels
{
public class ExportSetupViewModel : ViewModelBase, IExportSetupViewModel
{
private readonly ISettingsService _settingsService;
private string _filePath;
private ExportFormat _format;
private DateTime? _from;
private DateTime? _to;
public Guild Guild { get; private set; }
public Channel Channel { get; private set; }
public string FilePath
{
get => _filePath;
set
{
Set(ref _filePath, value);
ExportCommand.RaiseCanExecuteChanged();
}
}
public IReadOnlyList<ExportFormat> AvailableFormats { get; }
public ExportFormat SelectedFormat
{
get => _format;
set => Set(ref _format, value);
}
public DateTime? From
{
get => _from;
set => Set(ref _from, value);
}
public DateTime? To
{
get => _to;
set => Set(ref _to, value);
}
// Commands
public RelayCommand ExportCommand { get; }
public ExportSetupViewModel(ISettingsService settingsService)
{
_settingsService = settingsService;
// Defaults
AvailableFormats = Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
// Commands
ExportCommand = new RelayCommand(Export, () => FilePath.IsNotBlank());
// Messages
MessengerInstance.Register<ShowExportSetupMessage>(this, m =>
{
Guild = m.Guild;
Channel = m.Channel;
SelectedFormat = _settingsService.LastExportFormat;
FilePath = $"{Guild} - {Channel}.{SelectedFormat.GetFileExtension()}"
.Replace(Path.GetInvalidFileNameChars(), '_');
From = null;
To = null;
});
}
private void Export()
{
_settingsService.LastExportFormat = SelectedFormat;
MessengerInstance.Send(new StartExportMessage(Channel, FilePath, SelectedFormat, From, To));
}
}
}
@@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Models;
using GalaSoft.MvvmLight.CommandWpf;
namespace DiscordChatExporter.ViewModels
{
public interface IExportSetupViewModel
{
Guild Guild { get; }
Channel Channel { get; }
string FilePath { get; set; }
IReadOnlyList<ExportFormat> AvailableFormats { get; }
ExportFormat SelectedFormat { get; set; }
DateTime? From { get; set; }
DateTime? To { get; set; }
RelayCommand ExportCommand { get; }
}
}
@@ -16,8 +16,8 @@ namespace DiscordChatExporter.ViewModels
IReadOnlyList<Channel> AvailableChannels { get; } IReadOnlyList<Channel> AvailableChannels { get; }
RelayCommand PullDataCommand { get; } RelayCommand PullDataCommand { get; }
RelayCommand<Channel> ExportChannelCommand { get; }
RelayCommand ShowSettingsCommand { get; } RelayCommand ShowSettingsCommand { get; }
RelayCommand ShowAboutCommand { get; } RelayCommand ShowAboutCommand { get; }
RelayCommand<Channel> ShowExportSetupCommand { get; }
} }
} }
+62 -61
View File
@@ -1,14 +1,14 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using System.Linq; using System.Linq;
using System.Net;
using DiscordChatExporter.Exceptions; using DiscordChatExporter.Exceptions;
using DiscordChatExporter.Messages; using DiscordChatExporter.Messages;
using DiscordChatExporter.Models; using DiscordChatExporter.Models;
using DiscordChatExporter.Services; using DiscordChatExporter.Services;
using GalaSoft.MvvmLight; using GalaSoft.MvvmLight;
using GalaSoft.MvvmLight.CommandWpf; using GalaSoft.MvvmLight.CommandWpf;
using Microsoft.Win32;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.ViewModels namespace DiscordChatExporter.ViewModels
@@ -35,7 +35,7 @@ namespace DiscordChatExporter.ViewModels
{ {
Set(ref _isBusy, value); Set(ref _isBusy, value);
PullDataCommand.RaiseCanExecuteChanged(); PullDataCommand.RaiseCanExecuteChanged();
ExportChannelCommand.RaiseCanExecuteChanged(); ShowExportSetupCommand.RaiseCanExecuteChanged();
} }
} }
@@ -43,13 +43,13 @@ namespace DiscordChatExporter.ViewModels
public string Token public string Token
{ {
get => _settingsService.Token; get => _settingsService.LastToken;
set set
{ {
// Remove invalid chars // Remove invalid chars
value = value?.Trim('"'); value = value?.Trim('"');
_settingsService.Token = value; _settingsService.LastToken = value;
PullDataCommand.RaiseCanExecuteChanged(); PullDataCommand.RaiseCanExecuteChanged();
} }
} }
@@ -71,7 +71,7 @@ namespace DiscordChatExporter.ViewModels
{ {
Set(ref _selectedGuild, value); Set(ref _selectedGuild, value);
AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0]; AvailableChannels = value != null ? _guildChannelsMap[value] : new Channel[0];
ExportChannelCommand.RaiseCanExecuteChanged(); ShowExportSetupCommand.RaiseCanExecuteChanged();
} }
} }
@@ -82,9 +82,9 @@ namespace DiscordChatExporter.ViewModels
} }
public RelayCommand PullDataCommand { get; } public RelayCommand PullDataCommand { get; }
public RelayCommand<Channel> ExportChannelCommand { get; }
public RelayCommand ShowSettingsCommand { get; } public RelayCommand ShowSettingsCommand { get; }
public RelayCommand ShowAboutCommand { get; } public RelayCommand ShowAboutCommand { get; }
public RelayCommand<Channel> ShowExportSetupCommand { get; }
public MainViewModel(ISettingsService settingsService, IDataService dataService, public MainViewModel(ISettingsService settingsService, IDataService dataService,
IMessageGroupService messageGroupService, IExportService exportService) IMessageGroupService messageGroupService, IExportService exportService)
@@ -98,9 +98,15 @@ namespace DiscordChatExporter.ViewModels
// Commands // Commands
PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy); PullDataCommand = new RelayCommand(PullData, () => Token.IsNotBlank() && !IsBusy);
ExportChannelCommand = new RelayCommand<Channel>(ExportChannel, _ => !IsBusy);
ShowSettingsCommand = new RelayCommand(ShowSettings); ShowSettingsCommand = new RelayCommand(ShowSettings);
ShowAboutCommand = new RelayCommand(ShowAbout); ShowAboutCommand = new RelayCommand(ShowAbout);
ShowExportSetupCommand = new RelayCommand<Channel>(ShowExportSetup, _ => !IsBusy);
// Messages
MessengerInstance.Register<StartExportMessage>(this, m =>
{
Export(m.Channel, m.FilePath, m.Format, m.From, m.To);
});
} }
private async void PullData() private async void PullData()
@@ -126,14 +132,19 @@ namespace DiscordChatExporter.ViewModels
foreach (var guild in guilds) foreach (var guild in guilds)
{ {
var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id); var channels = await _dataService.GetGuildChannelsAsync(_cachedToken, guild.Id);
channels = channels.Where(c => c.Type == ChannelType.GuildTextChat); _guildChannelsMap[guild] = channels.Where(c => c.Type == ChannelType.GuildTextChat).ToArray();
_guildChannelsMap[guild] = channels.ToArray();
} }
} }
} }
catch (UnathorizedException) catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized)
{ {
MessengerInstance.Send(new ShowErrorMessage("Failed to authorize. Make sure the token is valid.")); const string message = "Unauthorized to perform request. Make sure token is valid.";
MessengerInstance.Send(new ShowErrorMessage(message));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
const string message = "Forbidden to perform request. The account may be locked by 2FA.";
MessengerInstance.Send(new ShowErrorMessage(message));
} }
AvailableGuilds = _guildChannelsMap.Keys.ToArray(); AvailableGuilds = _guildChannelsMap.Keys.ToArray();
@@ -141,54 +152,6 @@ namespace DiscordChatExporter.ViewModels
IsBusy = false; IsBusy = false;
} }
private async void ExportChannel(Channel channel)
{
IsBusy = true;
// Get safe file names
var safeGuildName = SelectedGuild.Name.Replace(Path.GetInvalidFileNameChars(), '_');
var safeChannelName = channel.Name.Replace(Path.GetInvalidFileNameChars(), '_');
// Ask for path
var sfd = new SaveFileDialog
{
FileName = $"{safeGuildName} - {safeChannelName}.html",
Filter = "HTML files (*.html)|*.html|All files (*.*)|*.*",
DefaultExt = "html",
AddExtension = true
};
if (sfd.ShowDialog() != true)
{
IsBusy = false;
return;
}
// Export
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(_cachedToken, channel.Id);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups);
// Export
await _exportService.ExportAsync(sfd.FileName, chatLog, _settingsService.Theme);
// Show dialog
MessengerInstance.Send(new ShowExportDoneMessage(sfd.FileName));
}
catch (UnathorizedException)
{
MessengerInstance.Send(new ShowErrorMessage("Failed to export. You don't have access to that channel."));
}
IsBusy = false;
}
private void ShowSettings() private void ShowSettings()
{ {
MessengerInstance.Send(new ShowSettingsMessage()); MessengerInstance.Send(new ShowSettingsMessage());
@@ -198,5 +161,43 @@ namespace DiscordChatExporter.ViewModels
{ {
Process.Start("https://github.com/Tyrrrz/DiscordChatExporter"); Process.Start("https://github.com/Tyrrrz/DiscordChatExporter");
} }
private void ShowExportSetup(Channel channel)
{
MessengerInstance.Send(new ShowExportSetupMessage(SelectedGuild, channel));
}
private async void Export(Channel channel, string filePath, ExportFormat format, DateTime? from, DateTime? to)
{
IsBusy = true;
try
{
// Get messages
var messages = await _dataService.GetChannelMessagesAsync(_cachedToken, channel.Id, from, to);
// Group them
var messageGroups = _messageGroupService.GroupMessages(messages);
// Create log
var chatLog = new ChannelChatLog(SelectedGuild, channel, messageGroups, messages.Count);
// Export
if (format == ExportFormat.Text)
await _exportService.ExportAsTextAsync(filePath, chatLog);
else if (format == ExportFormat.Html)
await _exportService.ExportAsHtmlAsync(filePath, chatLog, _settingsService.Theme);
// Notify completion
MessengerInstance.Send(new ShowExportDoneMessage(filePath));
}
catch (HttpErrorStatusCodeException ex) when (ex.StatusCode == HttpStatusCode.Forbidden)
{
const string message = "Forbidden to view messages in that channel.";
MessengerInstance.Send(new ShowErrorMessage(message));
}
IsBusy = false;
}
} }
} }
@@ -1,10 +1,4 @@
using System; namespace DiscordChatExporter.Views
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DiscordChatExporter.Views
{ {
public partial class ErrorDialog public partial class ErrorDialog
{ {
@@ -13,4 +7,4 @@ namespace DiscordChatExporter.Views
InitializeComponent(); InitializeComponent();
} }
} }
} }
@@ -18,7 +18,8 @@ UserControl "DiscordChatExporter.Views.ExportDoneDialog" {
HorizontalAlignment: Right HorizontalAlignment: Right
// Open // Open
Button { Button "OpenButton" {
Click: OpenButton_Click
Command: bind OpenCommand Command: bind OpenCommand
Content: "OPEN IT" Content: "OPEN IT"
Margin: 8 Margin: 8
@@ -1,8 +1,5 @@
using System; using System.Windows;
using System.Collections.Generic; using MaterialDesignThemes.Wpf;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DiscordChatExporter.Views namespace DiscordChatExporter.Views
{ {
@@ -12,5 +9,10 @@ namespace DiscordChatExporter.Views
{ {
InitializeComponent(); InitializeComponent();
} }
public void OpenButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
} }
} }
@@ -0,0 +1,69 @@
using MaterialDesignThemes.Wpf
UserControl "DiscordChatExporter.Views.ExportSetupDialog" {
DataContext: bind ExportSetupViewModel from $resource Container
Width: 350
StackPanel {
// File path
TextBox {
Margin: "16 16 16 8"
HintAssist.Hint: "Output file"
HintAssist.IsFloating: true
IsReadOnly: true
Text: bind FilePath
set [ UpdateSourceTrigger: PropertyChanged ]
}
// Date range
Grid {
#TwoColumns("*", "*")
DatePicker {
Grid.Column: 0
Margin: "16 16 8 8"
HintAssist.Hint: "From"
HintAssist.IsFloating: true
SelectedDate: bind From
}
DatePicker {
Grid.Column: 1
Margin: "8 16 16 8"
HintAssist.Hint: "To"
HintAssist.IsFloating: true
SelectedDate: bind To
}
}
// Buttons
@StackPanelHorizontal {
HorizontalAlignment: Right
// Export
Button "ExportButton" {
Click: ExportButton_Click
Command: bind ExportCommand
Content: "EXPORT"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
// Browse
Button "BrowseButton" {
Click: BrowseButton_Click
Content: "BROWSE"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
// Cancel
Button {
Command: DialogHost.CloseDialogCommand
Content: "CANCEL"
Margin: 8
Style: resource dyn "MaterialDesignFlatButton"
}
}
}
}
@@ -0,0 +1,55 @@
using System.Collections.Generic;
using System.Windows;
using DiscordChatExporter.Models;
using DiscordChatExporter.ViewModels;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
using Tyrrrz.Extensions;
namespace DiscordChatExporter.Views
{
public partial class ExportSetupDialog
{
private IExportSetupViewModel ViewModel => (IExportSetupViewModel) DataContext;
public ExportSetupDialog()
{
InitializeComponent();
}
private string GetFilter()
{
var filters = new List<string>();
foreach (var format in ViewModel.AvailableFormats)
{
var ext = format.GetFileExtension();
filters.Add($"{format} (*.{ext})|*.{ext}");
}
return filters.JoinToString("|");
}
public void ExportButton_Click(object sender, RoutedEventArgs args)
{
DialogHost.CloseDialogCommand.Execute(null, null);
}
public void BrowseButton_Click(object sender, RoutedEventArgs args)
{
var sfd = new SaveFileDialog
{
FileName = ViewModel.FilePath,
Filter = GetFilter(),
FilterIndex = ViewModel.AvailableFormats.IndexOf(ViewModel.SelectedFormat) + 1,
AddExtension = true,
Title = "Select output file"
};
if (sfd.ShowDialog() == true)
{
ViewModel.FilePath = sfd.FileName;
ViewModel.SelectedFormat = ViewModel.AvailableFormats[sfd.FilterIndex - 1];
}
}
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ Window "DiscordChatExporter.Views.MainWindow" {
Cursor: CursorType.Hand Cursor: CursorType.Hand
InputBindings: [ InputBindings: [
MouseBinding { MouseBinding {
Command: bind DataContext.ExportChannelCommand from $ancestor<ItemsControl> Command: bind DataContext.ShowExportSetupCommand from $ancestor<ItemsControl>
CommandParameter: bind CommandParameter: bind
MouseAction: LeftClick MouseAction: LeftClick
} }
+2 -1
View File
@@ -18,8 +18,9 @@ namespace DiscordChatExporter.Views
Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}"; Title += $" v{Assembly.GetExecutingAssembly().GetName().Version}";
Messenger.Default.Register<ShowErrorMessage>(this, m => DialogHost.Show(new ErrorDialog()).Forget()); Messenger.Default.Register<ShowErrorMessage>(this, m => DialogHost.Show(new ErrorDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this, m => DialogHost.Show(new SettingsDialog()).Forget());
Messenger.Default.Register<ShowExportDoneMessage>(this, m => DialogHost.Show(new ExportDoneDialog()).Forget()); Messenger.Default.Register<ShowExportDoneMessage>(this, m => DialogHost.Show(new ExportDoneDialog()).Forget());
Messenger.Default.Register<ShowExportSetupMessage>(this, m => DialogHost.Show(new ExportSetupDialog()).Forget());
Messenger.Default.Register<ShowSettingsMessage>(this, m => DialogHost.Show(new SettingsDialog()).Forget());
} }
public void TokenTextBox_KeyDown(object sender, KeyEventArgs e) public void TokenTextBox_KeyDown(object sender, KeyEventArgs e)
-1
View File
@@ -3,7 +3,6 @@
<package id="Ammy" version="1.2.87" targetFramework="net461" /> <package id="Ammy" version="1.2.87" targetFramework="net461" />
<package id="Ammy.WPF" 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="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="MaterialDesignColors" version="1.1.3" targetFramework="net461" />
<package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" /> <package id="MaterialDesignThemes" version="2.3.1.953" targetFramework="net461" />
<package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" /> <package id="MvvmLightLibs" version="5.3.0.0" targetFramework="net461" />
+13 -11
View File
@@ -1,10 +1,11 @@
# DiscordChatExporter # DiscordChatExporter
DiscordChatExporter can be used to export message history from [Discord](https://discordapp.com) to an HTML file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. There are options to configure the output, such as date format, color theme, message grouping limit, etc. DiscordChatExporter can be used to export message history from a [Discord](https://discordapp.com) channel to a file. It works for both direct message chats and guild chats, supports markdown, message grouping, and attachments. The tool also lets you select from/to dates to limit the exported messages. There are options to configure the output, such as date format, color theme, message grouping limit, etc.
## Screenshots ## Screenshots
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/3.png) ![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/1.png)
![](http://www.tyrrrz.me/Projects/DiscordChatExporter/Images/4.png)
## Download ## Download
@@ -12,15 +13,17 @@ DiscordChatExporter can be used to export message history from [Discord](https:/
## Features ## Features
- Exports to a self-contained HTML file - Intuitive GUI that displays available guilds and channels
- Supports both dark and light theme - Date ranges to limit messages
- Displays user avatars
- Groups messages by author and time - Groups messages by author and time
- Handles Discord markdown characters - Export to a plain text file
- Converts URLs to links - Export to an HTML file
- Inlines attached images with a link to the full version - Dark and light themes
- Inserts download links for other types of attached files - User avatars
- Marks edited messages with a timestamp - Inline image attachments
- Full markdown support
- Automatic links
- Styled similarly to the app
## Libraries used ## Libraries used
@@ -28,6 +31,5 @@ DiscordChatExporter can be used to export message history from [Discord](https:/
- [GalaSoft.MVVMLight](http://www.mvvmlight.net) - [GalaSoft.MVVMLight](http://www.mvvmlight.net)
- [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit) - [MaterialDesignInXamlToolkit](https://github.com/ButchersBoy/MaterialDesignInXamlToolkit)
- [Newtonsoft.Json](http://www.newtonsoft.com/json) - [Newtonsoft.Json](http://www.newtonsoft.com/json)
- [HtmlAgilityPack](https://github.com/zzzprojects/html-agility-pack)
- [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions) - [Tyrrrz.Extensions](https://github.com/Tyrrrz/Extensions)
- [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings) - [Tyrrrz.Settings](https://github.com/Tyrrrz/Settings)